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 is reserved for metadata enrichment.

Track dataclass

One audio file: its path and the album it belongs to.

Source code in src/musiscape/io.py
29
30
31
32
33
34
35
36
37
38
@dataclass
class Track:
    """One audio file: its path and the album it belongs to."""
    path: Path
    album: str

    @property
    def title(self) -> str:
        """Track title, taken from the filename stem."""
        return self.path.stem

title property

Track title, taken from the filename stem.

Album dataclass

One folder of tracks, named by its path relative to the root.

Source code in src/musiscape/io.py
41
42
43
44
45
@dataclass
class Album:
    """One folder of tracks, named by its path relative to the root."""
    name: str
    tracks: list[Track] = field(default_factory=list)

Collection dataclass

A scanned folder tree: the root path and its albums.

Source code in src/musiscape/io.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
@dataclass
class Collection:
    """A scanned folder tree: the root path and its albums."""
    root: Path
    albums: list[Album]

    @property
    def tracks(self) -> list[Track]:
        """Flat list of every track across all albums."""
        return [t for a in self.albums for t in a.tracks]

    @property
    def album_names(self) -> list[str]:
        """Album names in scan (sorted-path) order."""
        return [a.name for a in self.albums]

tracks property

Flat list of every track across all albums.

album_names property

Album names in scan (sorted-path) order.

open_collection(root)

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

Source code in src/musiscape/io.py
65
66
67
68
69
70
71
72
73
74
75
76
77
78
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)

recording_start_time(path)

When a recording started, as a naive local datetime, or None.

Read from the container's creation_time where there is one, else from a timestamp in the filename. Video containers store that tag in UTC, so it is converted to the local zone: what makes a session clock readable is the wall time of the room, not of Greenwich.

Returns None rather than guessing when neither is present.

Source code in src/musiscape/io.py
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
def recording_start_time(path: str | Path):
    """When a recording started, as a naive local ``datetime``, or ``None``.

    Read from the container's ``creation_time`` where there is one, else
    from a timestamp in the filename. Video containers store that tag in
    UTC, so it is converted to the local zone: what makes a session clock
    readable is the wall time of the room, not of Greenwich.

    Returns ``None`` rather than guessing when neither is present.
    """
    import datetime as _dt

    path = Path(path)
    m = _STAMP.search(path.name)
    if m:
        y, mo, d, hh, mi, ss = (int(g) for g in m.groups())
        y = y + 2000 if y < 100 else y
        try:
            return _dt.datetime(y, mo, d, hh, mi, ss)
        except ValueError:
            pass

    import shutil
    import subprocess
    if shutil.which("ffprobe") is None:
        return None
    proc = subprocess.run(
        ["ffprobe", "-v", "error", "-show_entries", "format_tags=creation_time",
         "-of", "default=nw=1:nk=1", str(path)],
        stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
    tag = proc.stdout.decode(errors="replace").strip()
    if not tag:
        return None
    try:
        when = _dt.datetime.fromisoformat(tag.replace("Z", "+00:00"))
    except ValueError:
        return None
    if when.tzinfo is not None:
        when = when.astimezone().replace(tzinfo=None)
    return when

album_stem(album)

Filename stem for a per-album output file.

Audio sitting in the collection root forms the album ".". Naming a file after it directly gives ..png, which Path reads as a dotfile with no suffix and PIL refuses to save, or ". medley.wav", which is hidden on every Unix desktop. Leading dots are therefore dropped.

Source code in src/musiscape/io.py
128
129
130
131
132
133
134
135
136
137
def album_stem(album: str) -> str:
    """Filename stem for a per-album output file.

    Audio sitting in the collection root forms the album ``"."``. Naming a
    file after it directly gives ``..png``, which Path reads as a dotfile
    with no suffix and PIL refuses to save, or ``". medley.wav"``, which is
    hidden on every Unix desktop. Leading dots are therefore dropped.
    """
    stem = album.replace("/", "_").lstrip(".")
    return stem or "collection"

list_recordings(root, exclude=())

Recordings under root, in name order, which is playing order.

Cameras number their files sequentially, so sorting by name puts a split concert back in the order it was played. A folder whose files are named otherwise needs the order fixed by renaming.

exclude names folders to skip. The concert tools write audio into an output folder that normally sits inside the input folder, so without this a second run would read the first run's songs back as recordings.

Source code in src/musiscape/io.py
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
def list_recordings(root: str | Path, exclude=()) -> list[Path]:
    """Recordings under ``root``, in name order, which is playing order.

    Cameras number their files sequentially, so sorting by name puts a
    split concert back in the order it was played. A folder whose files
    are named otherwise needs the order fixed by renaming.

    ``exclude`` names folders to skip. The concert tools write audio into
    an output folder that normally sits inside the input folder, so without
    this a second run would read the first run's songs back as recordings.
    """
    root = Path(root).expanduser().resolve()
    if not root.is_dir():
        raise FileNotFoundError(f"not a directory: {root}")
    skip = [Path(d).expanduser().resolve() for d in exclude]
    found = sorted(p for p in root.rglob("*")
                   if p.is_file() and p.suffix.lower() in RECORDING_EXTS
                   and not any(p.is_relative_to(d) for d in skip))
    if not found:
        raise FileNotFoundError(f"no recordings under {root}")
    return found

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
163
164
165
166
167
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

load_recording(path, sr=22050, offset=0.0, duration=None)

Load any recording, audio file or video container, as mono float.

Audio goes through librosa as everywhere else. Video is decoded by ffmpeg, which is not a package dependency: it is asked for only when a video file is actually handed over, and its absence is reported as a missing program rather than a decode failure.

Source code in src/musiscape/io.py
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
def load_recording(path: str | Path, sr: int = 22050, offset: float = 0.0,
                   duration: float | None = None):
    """Load any recording, audio file or video container, as mono float.

    Audio goes through librosa as everywhere else. Video is decoded by
    ffmpeg, which is not a package dependency: it is asked for only when a
    video file is actually handed over, and its absence is reported as a
    missing program rather than a decode failure.
    """
    path = Path(path)
    if path.suffix.lower() not in VIDEO_EXTS:
        import librosa
        return librosa.load(str(path), sr=sr, mono=True, offset=offset,
                            duration=duration)

    import shutil
    import subprocess

    import numpy as np

    if shutil.which("ffmpeg") is None:
        raise RuntimeError(
            f"reading {path.suffix} needs ffmpeg on PATH (install ffmpeg)")
    cmd = ["ffmpeg", "-v", "error", "-ss", f"{offset:.6f}", "-i", str(path)]
    if duration is not None:
        cmd += ["-t", f"{duration:.6f}"]
    cmd += ["-vn", "-ac", "1", "-ar", str(sr), "-f", "f32le", "-"]
    proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    if proc.returncode != 0:
        raise RuntimeError(f"ffmpeg failed on {path.name}: "
                           f"{proc.stderr.decode(errors='replace').strip()}")
    return np.frombuffer(proc.stdout, dtype=np.float32).copy(), sr

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

Load a track as a (2, n) stereo pair; mono files are duplicated.

Source code in src/musiscape/io.py
204
205
206
207
208
209
210
211
212
213
def load_stereo(track: Track, sr: int = 22050,
                duration: float | None = None):
    """Load a track as a (2, n) stereo pair; mono files are duplicated."""
    import librosa
    import numpy as np
    y, sr = librosa.load(str(track.path), sr=sr, mono=False,
                         duration=duration)
    if y.ndim == 1:
        y = np.stack([y, y])
    return y[:2], sr

Concerts & long recordings

Songs out of a continuous recording: the concert, not the collection.

The rest of musiscape assumes one file is one track. A concert is the other shape: one long recording, often several when the camera split at a file size limit, holding a sequence of songs separated by applause, tuning and talk. This module finds those songs so the collection tools can be pointed at them.

What separates a song from the space around it is not level. An enthusiastic room is as loud as the band. It is spectral flatness: applause is broadband noise and measures flat, while played music is tonal and measures peaked, typically an order of magnitude lower. The split between the two is taken from each recording's own distribution rather than from a fixed number, because the ratio survives across rooms and microphones while the absolute values do not.

music_mask(y, sr, hop_s=1.0)

Per-hop_s boolean: is this frame played music?

Frames are kept when they are both tonal (spectral flatness below a threshold taken from the recording's own bimodal distribution) and audible (within :data:LEVEL_FLOOR_DB of the recording's loud frames). When the flatness distribution has only one mode, as in a recording that is music throughout or applause throughout, the flatness test is dropped rather than invented and level alone decides.

Source code in src/musiscape/concert.py
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
def music_mask(y: np.ndarray, sr: int, hop_s: float = 1.0) -> np.ndarray:
    """Per-``hop_s`` boolean: is this frame played music?

    Frames are kept when they are both tonal (spectral flatness below a
    threshold taken from the recording's own bimodal distribution) and
    audible (within :data:`LEVEL_FLOOR_DB` of the recording's loud frames).
    When the flatness distribution has only one mode, as in a recording
    that is music throughout or applause throughout, the flatness test is
    dropped rather than invented and level alone decides.
    """
    import librosa

    hop = 512
    S = np.abs(librosa.stft(y, n_fft=2048, hop_length=hop))
    flat = librosa.feature.spectral_flatness(S=S)[0]
    rms = librosa.feature.rms(S=S)[0]

    m, sl = _pool_slices(len(y), sr, hop, hop_s, len(flat))
    if m == 0:
        return np.zeros(0, dtype=bool)
    logflat = np.array([np.log10(flat[s].mean() + 1e-12) for s in sl])
    db = librosa.amplitude_to_db(
        np.array([rms[s].mean() for s in sl]) + 1e-12)

    loud = db > np.percentile(db, 95) - LEVEL_FLOOR_DB
    cut, lo, hi = _otsu(logflat)
    tonal = logflat < cut
    share = min(tonal.mean(), 1.0 - tonal.mean())
    if hi - lo < BIMODAL_MIN_DECADES or share < BIMODAL_MIN_SHARE:
        return loud
    return loud & tonal

segments(mask, hop_s=1.0, min_song_s=60.0, min_gap_s=MIN_GAP_S)

Turn a music mask into song spans.

Runs of music separated by less than min_gap_s are one song, since a quiet bar or a held breath is not the end of a piece. Spans shorter than min_song_s are not songs at all, which keeps tuning, a spoken introduction over a held chord, and a false start out of the listing.

Returns one dict per song with start_s, end_s and duration_s, in time order.

Source code in src/musiscape/concert.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
def segments(mask: np.ndarray, hop_s: float = 1.0,
             min_song_s: float = 60.0,
             min_gap_s: float = MIN_GAP_S) -> list[dict]:
    """Turn a music mask into song spans.

    Runs of music separated by less than ``min_gap_s`` are one song, since
    a quiet bar or a held breath is not the end of a piece. Spans shorter
    than ``min_song_s`` are not songs at all, which keeps tuning, a spoken
    introduction over a held chord, and a false start out of the listing.

    Returns one dict per song with ``start_s``, ``end_s`` and
    ``duration_s``, in time order.
    """
    spans = _runs(np.asarray(mask, dtype=bool))
    if not spans:
        return []

    merged = [list(spans[0])]
    for a, b in spans[1:]:
        if (a - merged[-1][1]) * hop_s < min_gap_s:
            merged[-1][1] = b
        else:
            merged.append([a, b])

    out = []
    for a, b in merged:
        dur = (b - a) * hop_s
        if dur < min_song_s:
            continue
        out.append({"start_s": round(a * hop_s, 2),
                    "end_s": round(b * hop_s, 2),
                    "duration_s": round(dur, 2)})
    return out

find_songs(paths, sr=22050, hop_s=1.0, min_song_s=60.0, min_gap_s=MIN_GAP_S, join_tol_s=JOIN_TOL_S)

Locate the songs across an ordered sequence of recording files.

paths must be in playing order. Times are reported on a concert clock that runs from the start of the first file and treats the files as butted together: a camera that stops and restarts loses a few seconds at each join, and that loss is not recoverable from the audio, so the clock drifts behind wall time by however long the changeovers took. Within a song, parts carries the true offsets into each source file, which is what the clips are cut from.

A span reaching the end of one file and resuming at the start of the next is one song, because the camera splits at a size limit rather than at a musical boundary. The minimum-length test is applied after that join, so a song cut ten seconds before its end is not discarded as a fragment.

Source code in src/musiscape/concert.py
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
def find_songs(paths, sr: int = 22050, hop_s: float = 1.0,
               min_song_s: float = 60.0, min_gap_s: float = MIN_GAP_S,
               join_tol_s: float = JOIN_TOL_S) -> list[dict]:
    """Locate the songs across an ordered sequence of recording files.

    ``paths`` must be in playing order. Times are reported on a concert
    clock that runs from the start of the first file and treats the files
    as butted together: a camera that stops and restarts loses a few
    seconds at each join, and that loss is not recoverable from the audio,
    so the clock drifts behind wall time by however long the changeovers
    took. Within a song, ``parts`` carries the true offsets into each
    source file, which is what the clips are cut from.

    A span reaching the end of one file and resuming at the start of the
    next is one song, because the camera splits at a size limit rather than
    at a musical boundary. The minimum-length test is applied after that
    join, so a song cut ten seconds before its end is not discarded as a
    fragment.
    """
    paths = [Path(p) for p in paths]
    songs: list[dict] = []
    offset, prev_open = 0.0, False

    for p in paths:
        y, _sr = load_recording(p, sr=sr)
        dur = len(y) / _sr
        spans = segments(music_mask(y, _sr, hop_s), hop_s,
                         min_song_s=0.0, min_gap_s=min_gap_s)
        for j, s in enumerate(spans):
            part = {"file": p.name, "start_s": s["start_s"],
                    "end_s": s["end_s"]}
            if j == 0 and prev_open and s["start_s"] <= join_tol_s:
                songs[-1]["parts"].append(part)
                songs[-1]["end_s"] = round(offset + s["end_s"], 2)
                songs[-1]["duration_s"] = round(
                    songs[-1]["duration_s"] + s["duration_s"], 2)
            else:
                songs.append({"start_s": round(offset + s["start_s"], 2),
                              "end_s": round(offset + s["end_s"], 2),
                              "duration_s": s["duration_s"],
                              "parts": [part]})
        prev_open = bool(spans) and (dur - spans[-1]["end_s"]) <= join_tol_s
        offset += dur

    songs = [s for s in songs if s["duration_s"] >= min_song_s]
    for i, s in enumerate(songs, start=1):
        s["index"] = i
    return songs

split_recording(paths, out_dir, sr=22050, write_sr=44100, hop_s=1.0, min_song_s=60.0, min_gap_s=MIN_GAP_S, join_tol_s=JOIN_TOL_S)

Cut a concert into one audio file per song, plus a manifest.

Writes <out_dir>/songs/NN-<source>-<mmss>.flac and returns the path to <out_dir>/songs.json. The songs folder is an ordinary musiscape collection: point report, thumbnails or any other verb at it.

Detection runs at sr; the clips are written at write_sr so they stay worth listening to when you check a boundary by ear.

Source code in src/musiscape/concert.py
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
def split_recording(paths, out_dir: str | Path, sr: int = 22050,
                    write_sr: int = 44100, hop_s: float = 1.0,
                    min_song_s: float = 60.0, min_gap_s: float = MIN_GAP_S,
                    join_tol_s: float = JOIN_TOL_S) -> Path:
    """Cut a concert into one audio file per song, plus a manifest.

    Writes ``<out_dir>/songs/NN-<source>-<mmss>.flac`` and returns the path
    to ``<out_dir>/songs.json``. The songs folder is an ordinary musiscape
    collection: point ``report``, ``thumbnails`` or any other verb at it.

    Detection runs at ``sr``; the clips are written at ``write_sr`` so they
    stay worth listening to when you check a boundary by ear.
    """
    out_dir = Path(out_dir)
    songs_dir = out_dir / "songs"
    songs_dir.mkdir(parents=True, exist_ok=True)

    songs = find_songs(paths, sr=sr, hop_s=hop_s, min_song_s=min_song_s,
                       min_gap_s=min_gap_s, join_tol_s=join_tol_s)
    by_name = {Path(p).name: Path(p) for p in paths}

    import soundfile as sf
    for song in songs:
        pieces = []
        for part in song["parts"]:
            y, _ = load_recording(by_name[part["file"]], sr=write_sr,
                                  offset=part["start_s"],
                                  duration=part["end_s"] - part["start_s"])
            pieces.append(y)
        song["file"] = _clip_name(song)
        sf.write(songs_dir / song["file"], np.concatenate(pieces), write_sr)

    manifest = out_dir / "songs.json"
    manifest.write_text(json.dumps(songs, indent=1))
    return manifest

region_features(y, sr, hop_s=1.0)

Per-hop_s level, flatness, flatness variability and centroid.

Source code in src/musiscape/concert.py
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
def region_features(y: np.ndarray, sr: int, hop_s: float = 1.0) -> dict:
    """Per-``hop_s`` level, flatness, flatness variability and centroid."""
    import librosa

    hop = 512
    S = np.abs(librosa.stft(y, n_fft=2048, hop_length=hop))
    flat = np.log10(librosa.feature.spectral_flatness(S=S)[0] + 1e-12)
    rms = librosa.feature.rms(S=S)[0]
    cent = librosa.feature.spectral_centroid(S=S, sr=sr)[0]

    m, sl = _pool_slices(len(y), sr, hop, hop_s, len(flat))
    if m == 0:
        return {k: np.zeros(0) for k in
                ("db", "flatness", "flat_var", "centroid")}
    return {
        "db": librosa.amplitude_to_db(
            np.array([rms[s].mean() for s in sl]) + 1e-12),
        "flatness": np.array([flat[s].mean() for s in sl]),
        "flat_var": np.array([flat[s].std() for s in sl]),
        "centroid": np.array([cent[s].mean() for s in sl]),
    }

classify_regions(y, sr, hop_s=1.0)

Label every hop_s frame with one of :data:REGION_CLASSES.

Music must pass :func:music_mask and be tonal in absolute terms, since that function compares a recording against itself and has no way to tell an all-applause recording from an all-music one. The rest is sorted by flatness and by how much that flatness moves.

other is not a dustbin for what is left over; it is what the frame gets when it is audible but matches no class cleanly, and it should be read as the classifier declining to guess.

Source code in src/musiscape/concert.py
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
def classify_regions(y: np.ndarray, sr: int, hop_s: float = 1.0) -> np.ndarray:
    """Label every ``hop_s`` frame with one of :data:`REGION_CLASSES`.

    Music must pass :func:`music_mask` and be tonal in absolute terms, since
    that function compares a recording against itself and has no way to tell
    an all-applause recording from an all-music one. The rest is sorted by
    flatness and by how much that flatness moves.

    ``other`` is not a dustbin for what is left over; it is what the frame
    gets when it is audible but matches no class cleanly, and it should be
    read as the classifier declining to guess.
    """
    f = region_features(y, sr, hop_s)
    n = len(f["db"])
    if n == 0:
        return np.array([], dtype=object)

    labels = np.full(n, "other", dtype=object)
    music = music_mask(y, sr, hop_s)
    music = np.resize(music, n) if len(music) != n else music

    audible = f["db"] > np.percentile(f["db"], 95) - QUIET_FLOOR_DB
    labels[~audible] = "quiet"
    labels[audible & (f["flat_var"] >= VOICE_VARIABILITY)] = "voices"
    labels[audible & (f["flatness"] >= APPLAUSE_FLATNESS)
           & (f["flat_var"] < VOICE_VARIABILITY)] = "applause"
    labels[music & (f["flatness"] < MUSIC_FLATNESS)] = "music"
    return labels

regions(labels, hop_s=1.0, min_s=5.0)

Merge a label sequence into spans, dropping flickers.

A span shorter than min_s is absorbed into whichever neighbour it interrupts, because a second or two of a different label mid-song is the classifier wobbling rather than an event in the hall.

Source code in src/musiscape/concert.py
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
def regions(labels, hop_s: float = 1.0, min_s: float = 5.0) -> list[dict]:
    """Merge a label sequence into spans, dropping flickers.

    A span shorter than ``min_s`` is absorbed into whichever neighbour it
    interrupts, because a second or two of a different label mid-song is the
    classifier wobbling rather than an event in the hall.
    """
    labels = list(labels)
    if not labels:
        return []

    spans = []
    start = 0
    for i in range(1, len(labels) + 1):
        if i == len(labels) or labels[i] != labels[start]:
            spans.append([labels[start], start, i])
            start = i

    changed = True
    while changed and len(spans) > 1:
        changed = False
        for i, (_lab, a, b) in enumerate(spans):
            if (b - a) * hop_s >= min_s:
                continue
            # absorb into the longer neighbour, then re-merge equal labels
            prev = spans[i - 1] if i > 0 else None
            nxt = spans[i + 1] if i + 1 < len(spans) else None
            take = prev if (nxt is None or
                            (prev is not None and
                             (prev[2] - prev[1]) >= (nxt[2] - nxt[1]))) else nxt
            if take is None:
                continue
            take[1], take[2] = min(take[1], a), max(take[2], b)
            spans.pop(i)
            merged = [spans[0]]
            for s in spans[1:]:
                if s[0] == merged[-1][0]:
                    merged[-1][2] = s[2]
                else:
                    merged.append(s)
            spans = merged
            changed = True
            break

    return [{"label": lab, "start_s": round(a * hop_s, 2),
             "end_s": round(b * hop_s, 2),
             "duration_s": round((b - a) * hop_s, 2)}
            for lab, a, b in spans]

map_regions(paths, sr=22050, hop_s=1.0, min_s=5.0, songs=None, method='heuristic', device=None)

Label a whole concert, on the concert clock.

Returns {"spans": [...], "total_s": float, "level_db": [...]}, where the spans run continuously from the first file to the last and the level is one value per hop_s. Files are butted together exactly as :func:find_songs butts them, so the two share a clock.

Pass songs (what :func:find_songs returned) to let the setlist decide where the music is. It bridges a gap of a few seconds mid-song and the frame classifier does not, so without this a single song with a quiet bar in it is drawn as two or three. Everything outside the songs is still classified frame by frame.

method="panns" labels from AudioSet posteriors instead of spectral flatness (:mod:musiscape.tagging, needs ambiscape[ml]): slower, and right about loud rock and noise music where the heuristic is not. Music edges are then snapped to songs rather than replaced by them, and the onsets are refined to where the sound starts.

Source code in src/musiscape/concert.py
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
def map_regions(paths, sr: int = 22050, hop_s: float = 1.0,
                min_s: float = 5.0, songs=None, method: str = "heuristic",
                device=None) -> dict:
    """Label a whole concert, on the concert clock.

    Returns ``{"spans": [...], "total_s": float, "level_db": [...]}``, where
    the spans run continuously from the first file to the last and the level
    is one value per ``hop_s``. Files are butted together exactly as
    :func:`find_songs` butts them, so the two share a clock.

    Pass ``songs`` (what :func:`find_songs` returned) to let the setlist
    decide where the music is. It bridges a gap of a few seconds mid-song
    and the frame classifier does not, so without this a single song with a
    quiet bar in it is drawn as two or three. Everything outside the songs
    is still classified frame by frame.

    ``method="panns"`` labels from AudioSet posteriors instead of spectral
    flatness (:mod:`musiscape.tagging`, needs ``ambiscape[ml]``): slower, and
    right about loud rock and noise music where the heuristic is not. Music
    edges are then snapped to ``songs`` rather than replaced by them, and the
    onsets are refined to where the sound starts.
    """
    paths = [Path(p) for p in paths]
    if method == "panns":
        from . import tagging
        ys, level = [], []
        for p in paths:
            y, _sr = load_recording(p, sr=sr)
            ys.append(np.asarray(y, dtype=np.float32))
            level.extend(region_features(y, _sr, hop_s)["db"])
        res = tagging.segment_concert(np.concatenate(ys), sr, songs=songs, device=device)
        spans = [sp for sp in res["spans"] if sp["duration_s"] >= min_s] or res["spans"]
        return {"spans": spans, "total_s": res["total_s"],
                "level_db": [round(float(v), 2) for v in level]}
    if method != "heuristic":
        raise ValueError(f"method must be 'heuristic' or 'panns', not {method!r}")
    labels, level = [], []
    for p in paths:
        y, _sr = load_recording(p, sr=sr)
        labels.extend(classify_regions(y, _sr, hop_s))
        level.extend(region_features(y, _sr, hop_s)["db"])

    labels = np.array(labels, dtype=object)
    if songs is not None:
        labels[labels == "music"] = "other"
        for song in songs:
            a = int(round(song["start_s"] / hop_s))
            b = min(int(round(song["end_s"] / hop_s)), len(labels))
            labels[max(a, 0):b] = "music"

    return {"spans": regions(labels, hop_s, min_s),
            "total_s": round(len(labels) * hop_s, 2),
            "level_db": [round(float(v), 2) for v in level]}

export_regions(paths, out_dir, spans, sr=44100, exclude=('music',), hop_s=1.0, start_time=None, suffix='.flac')

Write every non-music span as its own file, for a soundscape tool.

musiscape describes music; what happens between the songs is a soundscape question, and ambiscape is the toolbox for those. The two meet at the file boundary rather than by importing one another, so this writes an ordinary folder of WAVs that ambiscape analyze reads as one session.

Spans are cut from the concert clock, so one crossing a file boundary is assembled from both files. FLAC by default: lossless, about half the size of WAV, and read natively by the tools on both sides.

start_time puts the recording's wall clock into the filenames, which is what lets the other tool lay the evening out on a timeline instead of stacking every span at the same second.

Source code in src/musiscape/concert.py
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
def export_regions(paths, out_dir: str | Path, spans, sr: int = 44100,
                   exclude=("music",), hop_s: float = 1.0,
                   start_time=None, suffix: str = ".flac") -> Path:
    """Write every non-music span as its own file, for a soundscape tool.

    musiscape describes music; what happens between the songs is a
    soundscape question, and ambiscape is the toolbox for those. The two
    meet at the file boundary rather than by importing one another, so this
    writes an ordinary folder of WAVs that ``ambiscape analyze`` reads as
    one session.

    Spans are cut from the concert clock, so one crossing a file boundary is
    assembled from both files. FLAC by default: lossless, about half the
    size of WAV, and read natively by the tools on both sides.

    ``start_time`` puts the recording's wall clock into the filenames, which
    is what lets the other tool lay the evening out on a timeline instead of
    stacking every span at the same second.
    """
    import soundfile as sf

    out_dir = Path(out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)
    paths = [Path(p) for p in paths]

    bounds, offset = [], 0.0
    for p in paths:
        y, _sr = load_recording(p, sr=22050)
        dur = len(y) / _sr
        bounds.append((p, offset, offset + dur))
        offset += dur

    for span in spans:
        if span["label"] in exclude:
            continue
        pieces = []
        for p, a, b in bounds:
            lo, hi = max(span["start_s"], a), min(span["end_s"], b)
            if hi - lo <= 0.05:
                continue
            y, _ = load_recording(p, sr=sr, offset=lo - a, duration=hi - lo)
            pieces.append(y)
        if pieces:
            sf.write(out_dir / _span_name(span, start_time, suffix),
                     np.concatenate(pieces), sr)
    return out_dir

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:micromotion.circular), and a Schaeffer TARTYP object profile. The set is deliberately small enough to explain; it does not 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.

Two descriptors answer even when there is nothing to answer, and both must be gated before use. This matters whenever the input is not a music collection: field recordings, broadcast audio, anything where "music" was decided by a detector rather than by a track listing.

tempo_bpm is librosa's prior-based estimator and it has no failure value: given an onset envelope with no periodicity it returns the tempogram bin nearest its 120 BPM prior. White noise returns 123.05 BPM, reproducibly. On 704 five-minute spans of domestic television audio it returned five distinct values in all, 93 % of them exactly 123.0, and the other four were the adjacent grid points, a result indistinguishable from noise. Read pulse_R first: below about 0.1 there is no pulse for a tempo to describe, and tempo_bpm is reporting the prior rather than the track.

key and key_conf degrade the same way. The Krumhansl--Schmuckler correlation is taken against whatever chroma vector arrives, including a near-uniform one. On the same material chroma_entropy sat at a median 3.541 against a maximum of log2(12) = 3.585, with 80 % of spans within 2 % of that ceiling: no tonal centre exists, so the estimate falls to whichever tiny bias survives, and it does so consistently: 78 % of spans came back minor and one key took a quarter of them. Consistency is not confidence here. Read chroma_entropy first; near the ceiling the key is an artefact, and splitting by key_conf will not reveal it, because the artefact is confident.

The spectral and temporal descriptors are unaffected and stay usable on such material: onset rate, centroid, flatness, zero-crossing rate, percussive ratio and dynamic range all varied normally on the same spans.

Both gates are whole-track averages, which is a second way to be wrong. They catch a descriptor answering about noise, but they do not distinguish that from a descriptor answering about four minutes of real music at too long a timescale. On live material the second case is the common one: a band drifting a few BPM collapses pulse_R while playing a steady beat, and a full band in a reverberant room flattens mean chroma far past a threshold calibrated on solo instrumental recordings.

:mod:musiscape.stability measures the same two quantities per window and reports how far the windows agree, which separates the cases. Its results travel beside the gated numbers here: key_agreement and key_windowed beside key, tempo_agreement and tempo_windowed_bpm beside tempo_bpm. They answer whether an estimate holds still across the track. Nothing answers whether a track has a pulse at all; see that module.

feats_2hz(y, sr)

Chroma, MFCC and RMS aggregated to 2 Hz frames.

Shared by the visual cards and the sonic thumbnails. It lives here rather than in :mod:thumbnails so that :mod:sonic, which has no visual output, need not import the plotting stack to use it.

Source code in src/musiscape/features.py
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
def feats_2hz(y, sr):
    """Chroma, MFCC and RMS aggregated to 2 Hz frames.

    Shared by the visual cards and the sonic thumbnails. It lives here
    rather than in :mod:`thumbnails` so that :mod:`sonic`, which has no
    visual output, need not import the plotting stack to use it.
    """
    import librosa
    hop = 512
    C = librosa.feature.chroma_cqt(y=y, sr=sr, hop_length=hop)
    M = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=13, hop_length=hop)[1:]
    rms = librosa.feature.rms(y=y, hop_length=hop)[0]
    step = max(1, int(0.5 * sr / hop))
    n = max(1, C.shape[1] // step)
    agg = lambda X: np.stack([X[:, i * step:(i + 1) * step].mean(1)
                              for i in range(n)], 1)
    return agg(C), agg(M), np.array([rms[i * step:(i + 1) * step].mean()
                                     for i in range(n)])

estimate_key(chroma_mean)

Krumhansl–Schmuckler key estimate (name, correlation).

Source code in src/musiscape/features.py
103
104
105
106
107
108
109
110
111
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
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
def extract_track(y: np.ndarray, sr: int) -> dict:
    """All per-track descriptors from decoded audio."""
    import librosa
    from . 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))

        chromagram = librosa.feature.chroma_cqt(y=yh, sr=sr)
        chroma = chromagram.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)

        # The shorter-timescale cross-check for the two gated descriptors.
        # Both reuse arrays computed above, so this costs almost nothing.
        from . import stability as astab
        ks = astab.key_stability(chromagram, sr)
        ts = astab.tempo_stability(flux, 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),
        "key_windowed": ks["key"],
        "key_agreement": ks["agreement"],
        "key_windows": ks["n_windows"],
        "tempo_windowed_bpm": ts["tempo_bpm"],
        "tempo_agreement": ts["agreement"],
        "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, retry_cap_s=600.0)

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

Tracks whose worker dies without raising, an out-of-memory kill on a very long track being the case seen in practice, are retried one at a time in their own process, with the analysis window capped to retry_cap_s seconds so the retry fits in memory. A capped result records analysis_capped_s so the shortened window is visible in the output rather than implied by a duration. Pass retry_cap_s=None to retry at full length, which will usually be killed again.

Nothing is capped on the first attempt, so ordinary collections are extracted exactly as before.

Source code in src/musiscape/features.py
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
def extract_collection(coll: Collection, out_dir: str | Path,
                       sr: int = 22050, duration: float | None = None,
                       workers: int = 4, force: bool = False,
                       retry_cap_s: float | None = 600.0) -> Path:
    """Extract every track (parallel, cached) → ``<out_dir>/features.json``.

    Tracks whose worker dies without raising, an out-of-memory kill on a
    very long track being the case seen in practice, are retried one at a
    time in their own process, with the analysis window capped to
    ``retry_cap_s`` seconds so the retry fits in memory. A capped result
    records ``analysis_capped_s`` so the shortened window is visible in the
    output rather than implied by a duration. Pass ``retry_cap_s=None`` to
    retry at full length, which will usually be killed again.

    Nothing is capped on the first attempt, so ordinary collections are
    extracted exactly as before.
    """
    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:
        results = _run_pool(list(enumerate(jobs)), workers)
    else:
        results = {i: r for i, j in enumerate(jobs) if (r := _work(j))}

    missing = [(i, j) for i, j in enumerate(jobs) if i not in results]
    if missing and workers > 1:
        print(f"{len(missing)} track(s) did not complete; retrying "
              f"individually" + (f", capped to {retry_cap_s:.0f}s"
                                 if retry_cap_s else ""), flush=True)
        for i, j in missing:
            cap = retry_cap_s
            if cap is not None and j[3] is not None:
                cap = min(j[3], cap)
            job = (j[0], j[1], j[2], cap, cap is not None)
            if r := _run_isolated(job):
                results[i] = r

    res = [results[i] for i in sorted(results)]
    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
302
303
304
def load_features(path: str | Path) -> list[dict]:
    """Read a ``features.json`` produced by :func:`extract_collection`."""
    return json.loads(Path(path).read_text())

Time-course of a recording

How a recording changes over its own length: pitch, harmony, pulse and timbre, second by second.

:mod:features describes a track by one number per descriptor, and :mod:stability asks whether that number holds still. This module gives the time-course itself, at one frame per second, for a recording that is meant to change: a concert, a long improvisation, a session in which the interesting question is not "what key is it in" but "when does it move, and in what".

Everything here is per second, on one clock, so the arrays can sit beside a motion or gaze track from another toolbox and be correlated, segmented and drawn on the same width. The descriptors:

  • Chroma of the harmonic component (HPSS first, so a piano's attacks and a room's noise do not smear the pitch classes), normalised per second; chroma entropy as harmonic complexity; tonal clarity as how far one pitch class stands out.
  • Key in overlapping windows by correlation with the Krumhansl–Kessler profiles, reported as a list of (time, key, correlation), because a key that changes every window is a statement about the music (modal, wandering) and not a failure.
  • Harmonic change as the tonnetz distance between consecutive seconds (Harte's HCDF).
  • Tempogram (onset-strength autocorrelation) with its per-second argmax as local tempo and its peak-to-mean ratio as pulse clarity; a windowed beat-tracked tempo alongside. On non-metric music these are honest reports of the absence of a pulse, and the module does not pretend otherwise: read pulse_clarity before reading local_tempo.
  • Timbre: MFCCs, spectral centroid, flatness, and the harmonic share from HPSS; timbre novelty from a Foote kernel on the MFCC self-similarity.
  • Register as the energy-weighted MIDI pitch of the harmonic constant-Q spectrum, with its spread, so "the violin went up" is a number.

:func:section_summary folds the time-course into one row per section for a table, and :func:timecourse_figures draws the three standard pictures (chromagram, tempogram, time-course). The functions take audio, not a track, so they work on a cut of a concert as readily as on a file. The time-course of the live-painting concert this was written for showed a music organised around pedal points rather than progressions, which the whole-track key estimate had hidden behind a single "C minor".

key_from_chroma(chroma_mean)

The best-matching key for a 12-bin chroma vector, and its profile correlation.

Parameters:

Name Type Description Default
chroma_mean

Twelve values, C first.

required

Returns:

Name Type Description
tuple str

(name, r) such as ("A minor", 0.71); the correlation is the maximum over

float

the 24 rotated profiles. A flat chroma gives a low r rather than an error.

Source code in src/musiscape/timecourse.py
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
def key_from_chroma(chroma_mean) -> tuple[str, float]:
    """The best-matching key for a 12-bin chroma vector, and its profile correlation.

    Args:
        chroma_mean: Twelve values, C first.

    Returns:
        tuple: ``(name, r)`` such as ``("A minor", 0.71)``; the correlation is the maximum over
        the 24 rotated profiles. A flat chroma gives a low ``r`` rather than an error.
    """
    v = np.asarray(chroma_mean, dtype=float)
    if v.std() < 1e-9:
        return "C major", 0.0
    best = (-2.0, "C major")
    for i in range(12):
        for prof, lab in ((MAJOR_PROFILE, "major"), (MINOR_PROFILE, "minor")):
            r = float(np.corrcoef(np.roll(prof, i), v)[0, 1])
            if r > best[0]:
                best = (r, f"{KEY_NAMES[i]} {lab}")
    return best[1], best[0]

foote_novelty(X, half_window)

Foote (2000) novelty of a (time, features) matrix with a checkerboard kernel.

Parameters:

Name Type Description Default
X ndarray

Standardised features, one row per frame.

required
half_window int

Kernel half-size in frames.

required

Returns:

Type Description
ndarray

numpy.ndarray: Novelty per frame, scaled to a maximum of 1; zero within

ndarray

half_window of the edges.

Source code in src/musiscape/timecourse.py
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
def foote_novelty(X: np.ndarray, half_window: int) -> np.ndarray:
    """Foote (2000) novelty of a (time, features) matrix with a checkerboard kernel.

    Args:
        X: Standardised features, one row per frame.
        half_window (int): Kernel half-size in frames.

    Returns:
        numpy.ndarray: Novelty per frame, scaled to a maximum of 1; zero within
        `half_window` of the edges.
    """
    Xn = X / (np.linalg.norm(X, axis=1, keepdims=True) + 1e-9)
    S = Xn @ Xn.T
    L = half_window
    sign = np.r_[-np.ones(L), np.ones(L)]
    k = np.outer(sign, sign) * np.outer(np.hanning(2 * L), np.hanning(2 * L))
    n = S.shape[0]
    nov = np.zeros(n)
    for i in range(L, n - L):
        nov[i] = (S[i - L:i + L, i - L:i + L] * k).sum()
    nov = np.maximum(nov, 0.0)
    return nov / (nov.max() + 1e-9)

music_timecourse(y, sr, hop=512, key_window_s=30.0, key_step_s=10.0, tempo_window_s=30.0, tempo_range=(40.0, 200.0), novelty_half_window_s=30.0)

The per-second time-course of a recording's pitch, harmony, pulse and timbre.

Parameters:

Name Type Description Default
y

Mono audio.

required
sr int

Sample rate.

required
hop int

STFT hop in samples for the underlying frame analysis. Defaults to 512.

512
key_window_s float

Window for the key estimates. Defaults to 30 s.

30.0
key_step_s float

Step between key windows. Defaults to 10 s.

10.0
tempo_window_s float

Window for the beat-tracked tempo. Defaults to 30 s.

30.0
tempo_range tuple

BPM range considered for local tempo and pulse clarity.

(40.0, 200.0)
novelty_half_window_s float

Half-size of the timbre-novelty kernel in seconds.

30.0

Returns:

Name Type Description
dict dict

t (seconds, one per row, bin centres), chroma (12, n) column-normalised,

dict

chroma_entropy, tonal_clarity, keys (list of (t, name, r)), hcdf,

dict

tempogram (bins, n) within tempo_range, tempi (BPM per bin), local_tempo,

dict

pulse_clarity, tempo_windows (list of (t, bpm)), mfcc (13, n),

dict

centroid, flatness, harmonic_ratio, register_midi,

dict

register_spread, rms, timbre_novelty, duration.

Source code in src/musiscape/timecourse.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
def music_timecourse(y, sr: int, hop: int = 512, key_window_s: float = 30.0,
                     key_step_s: float = 10.0, tempo_window_s: float = 30.0,
                     tempo_range=(40.0, 200.0), novelty_half_window_s: float = 30.0) -> dict:
    """The per-second time-course of a recording's pitch, harmony, pulse and timbre.

    Args:
        y: Mono audio.
        sr (int): Sample rate.
        hop (int): STFT hop in samples for the underlying frame analysis. Defaults to 512.
        key_window_s (float): Window for the key estimates. Defaults to 30 s.
        key_step_s (float): Step between key windows. Defaults to 10 s.
        tempo_window_s (float): Window for the beat-tracked tempo. Defaults to 30 s.
        tempo_range (tuple): BPM range considered for local tempo and pulse clarity.
        novelty_half_window_s (float): Half-size of the timbre-novelty kernel in seconds.

    Returns:
        dict: ``t`` (seconds, one per row, bin centres), ``chroma`` (12, n) column-normalised,
        ``chroma_entropy``, ``tonal_clarity``, ``keys`` (list of ``(t, name, r)``), ``hcdf``,
        ``tempogram`` (bins, n) within `tempo_range`, ``tempi`` (BPM per bin), ``local_tempo``,
        ``pulse_clarity``, ``tempo_windows`` (list of ``(t, bpm)``), ``mfcc`` (13, n),
        ``centroid``, ``flatness``, ``harmonic_ratio``, ``register_midi``,
        ``register_spread``, ``rms``, ``timbre_novelty``, ``duration``.
    """
    import librosa

    y = np.asarray(y, dtype=float)
    dur = len(y) / sr
    n = int(np.ceil(dur))
    yh, yp = librosa.effects.hpss(y, margin=(1.0, 3.0))
    chroma = librosa.feature.chroma_cqt(y=yh, sr=sr, hop_length=hop, bins_per_octave=36, n_chroma=12)
    fr_t = librosa.frames_to_time(np.arange(chroma.shape[1]), sr=sr, hop_length=hop)
    sec = np.clip(np.floor(fr_t).astype(int), 0, n - 1)
    ch = _per_second(chroma, sec, n)
    chn = ch / (ch.sum(0, keepdims=True) + 1e-9)
    entropy = -(chn * np.log2(chn + 1e-9)).sum(0) / np.log2(12)
    clarity = chn.max(0) - chn.mean(0)
    keys = []
    W = int(key_window_s)
    for s0 in range(0, max(n - W + 1, 1), max(int(key_step_s), 1)):
        name, r = key_from_chroma(ch[:, s0:s0 + W].mean(1))
        keys.append((s0 + W / 2, name, r))
    tz = _per_second(librosa.feature.tonnetz(chroma=chroma, sr=sr), sec, n)
    hcdf = np.r_[0.0, np.linalg.norm(np.diff(tz, axis=1), axis=0)]
    oenv = librosa.onset.onset_strength(y=y, sr=sr, hop_length=hop)
    tg = librosa.feature.tempogram(onset_envelope=oenv, sr=sr, hop_length=hop, win_length=int(8 * sr / hop))
    tempi = librosa.tempo_frequencies(tg.shape[0], sr=sr, hop_length=hop)
    valid = (tempi >= tempo_range[0]) & (tempi <= tempo_range[1])
    sec_o = np.clip(np.floor(librosa.frames_to_time(np.arange(tg.shape[1]), sr=sr, hop_length=hop)).astype(int), 0, n - 1)
    tg1 = _per_second(tg[valid], sec_o, n)
    local_tempo = np.array([tempi[valid][np.argmax(tg1[:, i])] if tg1[:, i].max() > 0 else np.nan for i in range(n)])
    pulse_clarity = tg1.max(0) / (tg1.mean(0) + 1e-9)
    tempo_windows = []
    Wt = int(tempo_window_s)
    for s0 in range(0, max(n - Wt + 1, 1), max(int(key_step_s), 1)):
        seg = oenv[int(s0 * sr / hop):int((s0 + Wt) * sr / hop)]
        if len(seg) < 4:
            continue
        try:
            est = librosa.feature.tempo(onset_envelope=seg, sr=sr, hop_length=hop, aggregate=None)
            tempo_windows.append((s0 + Wt / 2, float(np.median(est))))
        except Exception:  # pragma: no cover - librosa version differences
            tempo_windows.append((s0 + Wt / 2, float("nan")))
    S = np.abs(librosa.stft(y, n_fft=2048, hop_length=hop))
    Sh = np.abs(librosa.stft(yh, n_fft=2048, hop_length=hop))
    Sp = np.abs(librosa.stft(yp, n_fft=2048, hop_length=hop))
    sec_s = np.clip(np.floor(librosa.frames_to_time(np.arange(S.shape[1]), sr=sr, hop_length=hop)).astype(int), 0, n - 1)
    mfcc = _per_second(librosa.feature.mfcc(S=librosa.power_to_db(S ** 2), n_mfcc=13), sec_s, n)
    centroid = _per_second(librosa.feature.spectral_centroid(S=S, sr=sr), sec_s, n)[0]
    flatness = _per_second(librosa.feature.spectral_flatness(S=S), sec_s, n)[0]
    eh = _per_second((Sh ** 2).sum(0, keepdims=True), sec_s, n)[0]
    ep = _per_second((Sp ** 2).sum(0, keepdims=True), sec_s, n)[0]
    harmonic_ratio = eh / (eh + ep + 1e-12)
    rms = _per_second(librosa.feature.rms(S=S), sec_s, n)[0]
    cq = np.abs(librosa.cqt(yh, sr=sr, hop_length=hop, fmin=librosa.note_to_hz("C2"), n_bins=72, bins_per_octave=12))
    sec_c = np.clip(np.floor(librosa.frames_to_time(np.arange(cq.shape[1]), sr=sr, hop_length=hop)).astype(int), 0, n - 1)
    cq1 = _per_second(cq, sec_c, n)
    midi = librosa.note_to_midi("C2") + np.arange(72)
    register = (cq1 * midi[:, None]).sum(0) / (cq1.sum(0) + 1e-9)
    spread = np.sqrt(((cq1 * (midi[:, None] - register) ** 2).sum(0)) / (cq1.sum(0) + 1e-9))
    X = np.column_stack([(mfcc[i] - mfcc[i].mean()) / (mfcc[i].std() + 1e-9) for i in range(1, 13)])
    L = max(int(novelty_half_window_s), 2)
    timbre_novelty = foote_novelty(X, L) if n > 2 * L + 1 else np.zeros(n)
    return {
        "t": np.arange(n) + 0.5, "duration": dur, "chroma": chn, "chroma_entropy": entropy,
        "tonal_clarity": clarity, "keys": keys, "hcdf": hcdf, "tempogram": tg1, "tempi": tempi[valid],
        "local_tempo": local_tempo, "pulse_clarity": pulse_clarity, "tempo_windows": tempo_windows,
        "mfcc": mfcc, "centroid": centroid, "flatness": flatness, "harmonic_ratio": harmonic_ratio,
        "register_midi": register, "register_spread": spread, "rms": rms, "timbre_novelty": timbre_novelty,
    }

section_summary(tc, boundaries)

One row per section between boundaries (seconds): key, tempo, harmony, timbre, register.

Parameters:

Name Type Description Default
tc dict

From :func:music_timecourse.

required
boundaries

Section boundary times in seconds; the recording's start and end are added.

required

Returns:

Name Type Description
list list[dict]

Dicts with section, start, end, key, key_r, tempo_bpm_median,

list[dict]

pulse_clarity, chroma_entropy, hcdf, centroid_hz, flatness,

list[dict]

harmonic_ratio, register_midi, register_spread, rms_db.

Source code in src/musiscape/timecourse.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
def section_summary(tc: dict, boundaries) -> list[dict]:
    """One row per section between `boundaries` (seconds): key, tempo, harmony, timbre, register.

    Args:
        tc (dict): From :func:`music_timecourse`.
        boundaries: Section boundary times in seconds; the recording's start and end are added.

    Returns:
        list: Dicts with ``section``, ``start``, ``end``, ``key``, ``key_r``, ``tempo_bpm_median``,
        ``pulse_clarity``, ``chroma_entropy``, ``hcdf``, ``centroid_hz``, ``flatness``,
        ``harmonic_ratio``, ``register_midi``, ``register_spread``, ``rms_db``.
    """
    t = tc["t"]
    edges = np.r_[0.0, np.asarray(boundaries, dtype=float), tc["duration"]]
    tw = np.array([w[0] for w in tc["tempo_windows"]]) if tc["tempo_windows"] else np.array([])
    tv = np.array([w[1] for w in tc["tempo_windows"]]) if tc["tempo_windows"] else np.array([])
    rows = []
    for i in range(len(edges) - 1):
        m = (t >= edges[i]) & (t < edges[i + 1])
        if not m.any():
            continue
        name, r = key_from_chroma(tc["chroma"][:, m].mean(1))
        mt = (tw >= edges[i]) & (tw < edges[i + 1]) if len(tw) else np.array([], dtype=bool)
        rows.append({
            "section": i + 1, "start": float(edges[i]), "end": float(edges[i + 1]), "key": name, "key_r": r,
            "tempo_bpm_median": float(np.nanmedian(tv[mt])) if mt.any() else float("nan"),
            "pulse_clarity": float(np.nanmean(tc["pulse_clarity"][m])), "chroma_entropy": float(tc["chroma_entropy"][m].mean()),
            "hcdf": float(tc["hcdf"][m].mean()), "centroid_hz": float(tc["centroid"][m].mean()),
            "flatness": float(tc["flatness"][m].mean()), "harmonic_ratio": float(tc["harmonic_ratio"][m].mean()),
            "register_midi": float(tc["register_midi"][m].mean()), "register_spread": float(tc["register_spread"][m].mean()),
            "rms_db": float(20 * np.log10(tc["rms"][m].mean() + 1e-9)),
        })
    return rows

timecourse_figures(tc, out_dir, prefix='', title='', boundaries=())

Write the chromagram, tempogram and time-course figures, and the raw strips.

Parameters:

Name Type Description Default
tc dict

From :func:music_timecourse.

required
out_dir

Folder for the PNGs.

required
prefix str

Filename prefix.

''
title str

Figure title prefix.

''
boundaries

Section boundaries to draw as vertical lines.

()

Returns:

Name Type Description
dict dict

Paths of the files written (chromagram, tempogram, timecourse,

dict

chromagram_raw, tempogram_raw).

Source code in src/musiscape/timecourse.py
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
def timecourse_figures(tc: dict, out_dir, prefix: str = "", title: str = "", boundaries=()) -> dict:
    """Write the chromagram, tempogram and time-course figures, and the raw strips.

    Args:
        tc (dict): From :func:`music_timecourse`.
        out_dir: Folder for the PNGs.
        prefix (str): Filename prefix.
        title (str): Figure title prefix.
        boundaries: Section boundaries to draw as vertical lines.

    Returns:
        dict: Paths of the files written (``chromagram``, ``tempogram``, ``timecourse``,
        ``chromagram_raw``, ``tempogram_raw``).
    """
    import os
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt

    os.makedirs(out_dir, exist_ok=True)
    t, dur = tc["t"], tc["duration"]
    sm = lambda v, w=15: np.convolve(np.nan_to_num(v), np.ones(w) / w, "same")  # noqa: E731
    paths = {}
    fig, axs = plt.subplots(2, 1, figsize=(20, 7), sharex=True)
    axs[0].imshow(tc["chroma"], aspect="auto", origin="lower", cmap="magma", extent=[0, dur, -0.5, 11.5], interpolation="nearest")
    axs[0].set_yticks(range(12)); axs[0].set_yticklabels(KEY_NAMES); axs[0].set_ylim(-0.5, 15.5)
    for k, (tk, name, r) in enumerate(tc["keys"]):
        if k % 6 == 0:
            axs[0].text(tk, 11.7, name, fontsize=6, rotation=90, va="bottom", ha="center")
    axs[0].set_title(f"{title}: chromagram (harmonic component, 1 s) with key estimates and sections", pad=4)
    for b in boundaries:
        axs[0].axvline(b, color="cyan", lw=.8); axs[1].axvline(b, color="tab:blue", lw=.8)
    axs[1].plot(t, tc["chroma_entropy"], color="grey", lw=.8, label="chroma entropy")
    axs[1].plot(t, sm(tc["hcdf"], 10) / (np.nanmax(sm(tc["hcdf"], 10)) + 1e-9), color="tab:purple", lw=.8, label="harmonic change (normalised)")
    axs[1].plot(t, tc["tonal_clarity"] * 3, color="tab:orange", lw=.8, label="tonal clarity (x3)")
    axs[1].legend(ncol=3, fontsize=8); axs[1].set_xlabel("time (s)"); axs[1].set_xlim(0, dur); axs[1].set_ylim(0, 1.05)
    fig.tight_layout(); paths["chromagram"] = os.path.join(out_dir, f"{prefix}chromagram.png"); fig.savefig(paths["chromagram"]); plt.close(fig)
    tgn = tc["tempogram"] / (tc["tempogram"].max(0, keepdims=True) + 1e-9)
    fig, axs = plt.subplots(2, 1, figsize=(20, 7), sharex=True)
    axs[0].imshow(tgn, aspect="auto", origin="lower", cmap="magma", extent=[0, dur, tc["tempi"].min(), tc["tempi"].max()], interpolation="nearest")
    if tc["tempo_windows"]:
        axs[0].plot([w[0] for w in tc["tempo_windows"]], [w[1] for w in tc["tempo_windows"]], color="white", lw=1)
    axs[0].set_ylabel("tempo (BPM)"); axs[0].set_title(f"{title}: tempogram with windowed tempo estimate (white)")
    axs[1].plot(t, sm(tc["pulse_clarity"], 10), color="tab:blue", lw=1, label="pulse clarity (peak/mean, 10 s smooth)")
    axs[1].legend(fontsize=8); axs[1].set_xlabel("time (s)"); axs[1].set_xlim(0, dur)
    for b in boundaries:
        axs[0].axvline(b, color="cyan", lw=.8); axs[1].axvline(b, color="tab:blue", lw=.8)
    fig.tight_layout(); paths["tempogram"] = os.path.join(out_dir, f"{prefix}tempogram.png"); fig.savefig(paths["tempogram"]); plt.close(fig)
    lvl = 20 * np.log10(tc["rms"] + 1e-9); lvl = np.maximum(lvl, np.nanpercentile(lvl, 1) - 5)
    fig, axs = plt.subplots(5, 1, figsize=(20, 11), sharex=True)
    axs[0].plot(t, lvl, color="tab:blue", lw=.5, alpha=.4); axs[0].plot(t, sm(lvl), color="tab:blue", lw=1.2); axs[0].set_ylabel("level (dB)")
    axs[1].plot(t, sm(tc["register_midi"]), color="tab:purple", lw=1.2, label="register (energy-weighted MIDI)")
    axs[1].fill_between(t, sm(tc["register_midi"] - tc["register_spread"]), sm(tc["register_midi"] + tc["register_spread"]), color="tab:purple", alpha=.2, label="± spread")
    axs[1].set_ylabel("pitch (MIDI)"); axs[1].legend(fontsize=8)
    axs[2].plot(t, sm(tc["centroid"]), color="tab:orange", lw=1.2, label="spectral centroid (Hz)"); axs[2].legend(fontsize=8); axs[2].set_ylabel("brightness (Hz)")
    axs[3].plot(t, sm(tc["harmonic_ratio"]), color="tab:green", lw=1.2, label="harmonic share (HPSS)")
    axs[3].plot(t, sm(tc["flatness"] * 10), color="grey", lw=1.2, label="spectral flatness (x10)"); axs[3].set_ylim(0, 1); axs[3].legend(fontsize=8); axs[3].set_ylabel("timbre")
    axs[4].plot(t, tc["timbre_novelty"], color="tab:red", lw=1, label="timbre novelty (MFCC)")
    axs[4].plot(t, sm(tc["hcdf"]) / (np.nanmax(sm(tc["hcdf"])) + 1e-9), color="tab:purple", lw=1, label="harmonic change (normalised)")
    axs[4].legend(fontsize=8); axs[4].set_ylabel("change"); axs[4].set_xlabel("time (s)"); axs[4].set_xlim(0, dur)
    for ax in axs:
        for b in boundaries:
            ax.axvline(b, color="tab:blue", lw=.6, alpha=.7)
    axs[0].set_title(f"{title}: musical time-course")
    fig.tight_layout(); paths["timecourse"] = os.path.join(out_dir, f"{prefix}timecourse.png"); fig.savefig(paths["timecourse"]); plt.close(fig)
    paths["chromagram_raw"] = os.path.join(out_dir, f"{prefix}chromagram_raw.png")
    plt.imsave(paths["chromagram_raw"], tc["chroma"][::-1] ** 0.7, cmap="magma")
    paths["tempogram_raw"] = os.path.join(out_dir, f"{prefix}tempogram_raw.png")
    plt.imsave(paths["tempogram_raw"], tgn[::-1], cmap="magma")
    return paths

Foreground proxies

Which sound is in front, second by second, when there is no multitrack.

A concert recorded from one microphone gives one mixture. When the question is "what did the painter do when the violin led, and when the electronics led", the honest answer without stems is a set of proxies: envelopes that track the kind of sound each instrument makes rather than the instrument itself. This module computes three such envelopes per second and says plainly what each one is.

  • pitched --- energy of the harmonic component (HPSS) between pitched_band, weighted by the voicing probability of a pitch tracker (pYIN) run on that component. A bowed string, a voice or a wind instrument playing notes scores high; a synthesiser drone with a clear pitch scores high too, which is why this is a proxy and not a separation.
  • low --- energy below low_hz. Bass, sub-bass and the weight of a PA.
  • noise --- the percussive/residual component's energy plus the spectral flatness of the mixture: attacks, noise textures, applause, brushes on canvas.

Each envelope is scaled to its own 99th percentile, and :func:foreground_labels turns the three into one label per second ("pitched", "low", "noise", "mixed" or "quiet") with a margin, so a second is labelled only when one proxy clearly leads. On the live-painting concert this was written for, the pitched proxy followed the violin and the low+noise proxies the electronics; that reading was checked against the photographs and the AudioSet tagger, not assumed. Check yours the same way before calling a proxy an instrument.

instrument_foreground(y, sr, hop=512, pitched_band=(180.0, 4000.0), low_hz=180.0, fmin=150.0, fmax=3000.0)

Per-second proxies for pitched, low and noise-like foreground.

Parameters:

Name Type Description Default
y

Mono audio.

required
sr int

Sample rate.

required
hop int

Hop for the frame analysis. Defaults to 512.

512
pitched_band tuple

Frequency band, Hz, of the harmonic energy behind pitched.

(180.0, 4000.0)
low_hz float

Upper edge of the low band. Defaults to 180 Hz.

180.0
fmin, fmax float

Pitch-tracker range in Hz. Defaults to 150–3000 Hz.

required

Returns:

Name Type Description
dict dict

t (bin centres in seconds), pitched, low, noise (each scaled 0–1 by

dict

its 99th percentile), voiced (pYIN voicing probability per second), f0_midi

dict

(median voiced pitch per second, NaN where unvoiced) and level_db.

Source code in src/musiscape/foreground.py
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
def instrument_foreground(y, sr: int, hop: int = 512, pitched_band=(180.0, 4000.0),
                          low_hz: float = 180.0, fmin: float = 150.0, fmax: float = 3000.0) -> dict:
    """Per-second proxies for pitched, low and noise-like foreground.

    Args:
        y: Mono audio.
        sr (int): Sample rate.
        hop (int): Hop for the frame analysis. Defaults to 512.
        pitched_band (tuple): Frequency band, Hz, of the harmonic energy behind ``pitched``.
        low_hz (float): Upper edge of the ``low`` band. Defaults to 180 Hz.
        fmin, fmax (float): Pitch-tracker range in Hz. Defaults to 150–3000 Hz.

    Returns:
        dict: ``t`` (bin centres in seconds), ``pitched``, ``low``, ``noise`` (each scaled 0–1 by
        its 99th percentile), ``voiced`` (pYIN voicing probability per second), ``f0_midi``
        (median voiced pitch per second, NaN where unvoiced) and ``level_db``.
    """
    import librosa

    y = np.asarray(y, dtype=float)
    n = int(np.ceil(len(y) / sr))
    yh, yp = librosa.effects.hpss(y, margin=(1.0, 3.0))
    S = np.abs(librosa.stft(y, n_fft=2048, hop_length=hop))
    Sh = np.abs(librosa.stft(yh, n_fft=2048, hop_length=hop))
    Sp = np.abs(librosa.stft(yp, n_fft=2048, hop_length=hop))
    freqs = librosa.fft_frequencies(sr=sr, n_fft=2048)
    fr = librosa.frames_to_time(np.arange(S.shape[1]), sr=sr, hop_length=hop)
    sec = np.clip(np.floor(fr).astype(int), 0, n - 1)
    cnt = np.maximum(np.bincount(sec, minlength=n), 1)

    def per_sec(v):
        return np.bincount(sec, weights=v[:len(sec)], minlength=n) / cnt

    band = (freqs >= pitched_band[0]) & (freqs <= pitched_band[1])
    e_pitched = per_sec((Sh[band] ** 2).sum(0))
    e_low = per_sec((S[freqs < low_hz] ** 2).sum(0))
    e_noise = per_sec((Sp ** 2).sum(0))
    flat = per_sec(librosa.feature.spectral_flatness(S=S)[0])
    f0, voiced_flag, voiced_prob = librosa.pyin(yh, fmin=fmin, fmax=fmax, sr=sr, hop_length=hop)
    m = min(len(voiced_prob), len(sec))
    voiced = np.bincount(sec[:m], weights=np.nan_to_num(voiced_prob[:m]), minlength=n) / cnt
    f0m = np.full(n, np.nan)
    midi = librosa.hz_to_midi(np.where(np.isnan(f0), np.nan, f0))
    for s in range(n):
        sel = (sec[:m] == s) & ~np.isnan(midi[:m])
        if sel.any():
            f0m[s] = float(np.median(midi[:m][sel]))
    scale = lambda v: v / (np.percentile(v, 99) + 1e-12)  # noqa: E731
    pitched = scale(e_pitched * voiced)
    low = scale(e_low)
    noise = scale(e_noise * (0.5 + flat))
    level = 20 * np.log10(per_sec(librosa.feature.rms(S=S)[0]) + 1e-9)
    return {"t": np.arange(n) + 0.5, "pitched": pitched, "low": low, "noise": noise, "voiced": voiced, "f0_midi": f0m, "level_db": level}

foreground_labels(fg, margin=0.15, quiet_db=-55.0)

One label per second from the three proxies.

Parameters:

Name Type Description Default
fg dict

From :func:instrument_foreground.

required
margin float

How far the leading proxy must exceed the runner-up. Defaults to 0.15.

0.15
quiet_db float

Seconds below this level are "quiet". Defaults to −55 dBFS.

-55.0

Returns:

Type Description
ndarray

numpy.ndarray: Strings "pitched", "low", "noise", "mixed" or "quiet".

Source code in src/musiscape/foreground.py
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
def foreground_labels(fg: dict, margin: float = 0.15, quiet_db: float = -55.0) -> np.ndarray:
    """One label per second from the three proxies.

    Args:
        fg (dict): From :func:`instrument_foreground`.
        margin (float): How far the leading proxy must exceed the runner-up. Defaults to 0.15.
        quiet_db (float): Seconds below this level are ``"quiet"``. Defaults to −55 dBFS.

    Returns:
        numpy.ndarray: Strings ``"pitched"``, ``"low"``, ``"noise"``, ``"mixed"`` or ``"quiet"``.
    """
    P = np.vstack([fg["pitched"], fg["low"], fg["noise"]])
    names = np.array(["pitched", "low", "noise"])
    order = np.argsort(-P, axis=0)
    lead = P[order[0], np.arange(P.shape[1])]
    second = P[order[1], np.arange(P.shape[1])]
    lab = np.where(lead - second >= margin, names[order[0]], "mixed")
    lab = np.where(fg["level_db"] < quiet_db, "quiet", lab)
    return lab

Piano transcription

Notes, not onsets: piano transcription for the analyses that need to know what was played.

Everything else in this toolbox works from the signal. For a piano recording that is a loss: an onset detector fires on attacks without saying how many notes, at what pitch, how loud, and it fires on the pianist's chair as readily as on a chord. This module transcribes the piano part to note events --- onset, offset, MIDI pitch, velocity --- with the high-resolution transcription model of Kong et al. (2021), which runs on a CPU at a few times real time and was trained on solo piano. It is only for piano, and only for recordings in which the piano dominates; on a mixture it returns the notes it believes it hears, which may be many.

Two products follow from the notes. :func:notes_per_second folds them onto the one-second clock the rest of the toolbox uses (density, mean pitch, mean velocity, pitch spread), so they sit beside a motion or gaze track. The note onsets themselves are the events that MGT-python's event_alignment tests strokes and gestures against; on the painter--pianist session this was written for, the transcription found 58, 260 and 300 notes per minute in the three takes where the onset detector had found 32, 78 and 83, because chords and fast passages had been merged into single onsets.

The model is an optional dependency: pip install "musiscape[transcribe]". Its checkpoint (about 170 MB) is downloaded on first use to ~/piano_transcription_inference_data.

transcription_available()

Whether the optional transcription package is installed.

Source code in src/musiscape/transcribe.py
32
33
34
35
36
37
38
def transcription_available() -> bool:
    """Whether the optional transcription package is installed."""
    try:
        import piano_transcription_inference  # noqa: F401
    except ImportError:
        return False
    return True

transcribe_piano(y, sr, midi_path=None, device='cpu', threads=None)

Transcribe a piano recording to note events.

Parameters:

Name Type Description Default
y

Mono audio.

required
sr int

Its sample rate; resampled to the model's 16 kHz.

required
midi_path optional

Where to write a MIDI file of the result. Defaults to none.

None
device str

"cpu" or "cuda". Defaults to CPU, which runs at a few times real time.

'cpu'
threads int

Torch threads to use. Defaults to torch's choice.

None

Returns:

Type Description

pandas.DataFrame: One row per note with onset_s, offset_s, midi and

velocity (0–127), sorted by onset. Empty when nothing was heard.

Source code in src/musiscape/transcribe.py
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
def transcribe_piano(y, sr: int, midi_path=None, device: str = "cpu", threads: int | None = None):
    """Transcribe a piano recording to note events.

    Args:
        y: Mono audio.
        sr (int): Its sample rate; resampled to the model's 16 kHz.
        midi_path (optional): Where to write a MIDI file of the result. Defaults to none.
        device (str): ``"cpu"`` or ``"cuda"``. Defaults to CPU, which runs at a few times real time.
        threads (int, optional): Torch threads to use. Defaults to torch's choice.

    Returns:
        pandas.DataFrame: One row per note with ``onset_s``, ``offset_s``, ``midi`` and
        ``velocity`` (0–127), sorted by onset. Empty when nothing was heard.
    """
    import pandas as pd
    import librosa
    PianoTranscription, model_sr = _require()
    if threads:
        import torch
        torch.set_num_threads(int(threads))
    y = np.asarray(y, dtype=np.float32)
    if sr != model_sr:
        y = librosa.resample(y, orig_sr=sr, target_sr=model_sr)
    tr = PianoTranscription(device=device, checkpoint_path=None)
    out = tr.transcribe(y, midi_path if midi_path is not None else None)
    events = out.get("est_note_events", [])
    if not events:
        return pd.DataFrame(columns=["onset_s", "offset_s", "midi", "velocity"])
    notes = pd.DataFrame([{"onset_s": float(n["onset_time"]), "offset_s": float(n["offset_time"]),
                           "midi": int(n["midi_note"]), "velocity": int(n["velocity"])} for n in events])
    return notes.sort_values("onset_s").reset_index(drop=True)

notes_per_second(notes, duration_s, bin_s=1.0)

Fold note events onto a per-bin clock.

Parameters:

Name Type Description Default
notes

The table from :func:transcribe_piano (or any with onset_s, midi, velocity).

required
duration_s float

Length of the recording.

required
bin_s float

Bin width in seconds. Defaults to 1.0.

1.0

Returns:

Name Type Description
dict dict

t (bin centres), density (notes per bin), pitch_mean and

dict

velocity_mean (NaN in empty bins), pitch_spread (standard deviation of pitch

dict

within the bin, NaN with fewer than two notes), sustain (notes sounding during the

dict

bin, counting offsets).

Source code in src/musiscape/transcribe.py
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
def notes_per_second(notes, duration_s: float, bin_s: float = 1.0) -> dict:
    """Fold note events onto a per-bin clock.

    Args:
        notes: The table from :func:`transcribe_piano` (or any with ``onset_s``, ``midi``,
            ``velocity``).
        duration_s (float): Length of the recording.
        bin_s (float): Bin width in seconds. Defaults to 1.0.

    Returns:
        dict: ``t`` (bin centres), ``density`` (notes per bin), ``pitch_mean`` and
        ``velocity_mean`` (NaN in empty bins), ``pitch_spread`` (standard deviation of pitch
        within the bin, NaN with fewer than two notes), ``sustain`` (notes sounding during the
        bin, counting offsets).
    """
    n = int(np.ceil(duration_s / bin_s))
    on = np.asarray(notes["onset_s"], dtype=float)
    off = np.asarray(notes["offset_s"], dtype=float) if "offset_s" in notes else on + 0.1
    midi = np.asarray(notes["midi"], dtype=float)
    vel = np.asarray(notes["velocity"], dtype=float)
    b = np.clip((on / bin_s).astype(int), 0, n - 1)
    density = np.bincount(b, minlength=n).astype(float)
    with np.errstate(invalid="ignore", divide="ignore"):
        pitch_mean = np.bincount(b, weights=midi, minlength=n) / density
        vel_mean = np.bincount(b, weights=vel, minlength=n) / density
        sq = np.bincount(b, weights=midi ** 2, minlength=n) / density
        spread = np.sqrt(np.maximum(sq - pitch_mean ** 2, 0))
    pitch_mean[density == 0] = np.nan
    vel_mean[density == 0] = np.nan
    spread[density < 2] = np.nan
    sustain = np.zeros(n)
    for a, z in zip(on, off):
        lo, hi = int(a / bin_s), min(int(z / bin_s), n - 1)
        sustain[lo:hi + 1] += 1
    return {"t": (np.arange(n) + 0.5) * bin_s, "density": density, "pitch_mean": pitch_mean,
            "velocity_mean": vel_mean, "pitch_spread": spread, "sustain": sustain}

Estimate stability

Does an estimate hold up across the track, or only on average?

:mod:features gates two descriptors on whole-track statistics: near-uniform chroma makes key an artefact, and an onset envelope with no periodicity makes tempo_bpm a report of librosa's prior. Both gates catch real failures. Both are also averages over the whole track, which is a second way to be wrong: not by measuring noise, but by measuring something real over too long a window.

Live music is where the difference shows. pulse_R folds an entire take at one global period, so a band that drifts a few BPM across four minutes collapses the resultant while playing a steady beat. chroma_entropy averages chroma over the whole take, and a full band in a reverberant room flattens that average far past a threshold calibrated on solo instrumental material.

This module measures the same two quantities per window and reports how far the windows agree. High agreement on a gated track means the gate was too coarse for the material. Low agreement means the track really does wander, which is worth knowing and is a different statement from "unmeasurable".

The question answered is therefore narrow and answerable: not "is there a pulse" but "does the estimate hold still". No descriptor here reports whether a track has a beat at all, because on real material no statistic of periodicity can tell one. Applause is rhythmic, so a room clapping in near-unison scores in the same range as the band it is applauding, whether measured by beat strength or by tempogram peak prominence. Separating the two is what :mod:musiscape.concert uses spectral flatness for, and on a single track the tempogram figure read by eye is the honest answer.

Both functions take features already computed elsewhere (a chromagram, an onset envelope) rather than audio, so adding them to an extraction costs almost nothing: :func:features.extract_track has both in hand.

key_stability(chroma, sr, hop=512, win_s=WIN_S)

Krumhansl--Schmuckler key per window, and how often they agree.

chroma is a (12, frames) chromagram, chroma_cqt on the harmonic component as :mod:features computes it. Returns the modal key across windows, the share of windows holding it, and the window count.

agreement is None when only one window fits: a single window agrees with itself trivially, and reporting 1.0 for a short track would make the least evidence look like the most.

Source code in src/musiscape/stability.py
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
def key_stability(chroma: np.ndarray, sr: int, hop: int = 512,
                  win_s: float = WIN_S) -> dict:
    """Krumhansl--Schmuckler key per window, and how often they agree.

    ``chroma`` is a (12, frames) chromagram, ``chroma_cqt`` on the harmonic
    component as :mod:`features` computes it. Returns the modal key across
    windows, the share of windows holding it, and the window count.

    ``agreement`` is ``None`` when only one window fits: a single window
    agrees with itself trivially, and reporting 1.0 for a short track would
    make the least evidence look like the most.
    """
    chroma = np.asarray(chroma, float)
    n, m = _n_windows(chroma.shape[1], sr, hop, win_s)
    keys = [estimate_key(chroma[:, i * n:(i + 1) * n].mean(axis=1))[0]
            for i in range(m)]
    modal = max(set(keys), key=keys.count)
    return {"key": modal,
            "agreement": round(keys.count(modal) / len(keys), 3)
            if len(keys) > 1 else None,
            "n_windows": len(keys), "keys": keys}

tempo_stability(onset_env, sr, hop=512, win_s=WIN_S, tol=TEMPO_TOL)

Tempo per window, and how often the windows agree.

onset_env is an onset-strength envelope. Returns the median windowed tempo, the share of windows within tol of it, and the number of windows.

Nothing is returned for "is there a beat at all"; see the module docstring for why that question has no reliable answer here.

Windowed tempos are folded by metrical octave before being compared. A window heard at double or half time agrees about where the beat is, and counting it as disagreement would make every syncopated track look unstable.

Source code in src/musiscape/stability.py
 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
108
109
110
111
112
113
114
def tempo_stability(onset_env: np.ndarray, sr: int, hop: int = 512,
                    win_s: float = WIN_S, tol: float = TEMPO_TOL) -> dict:
    """Tempo per window, and how often the windows agree.

    ``onset_env`` is an onset-strength envelope. Returns the median
    windowed tempo, the share of windows within ``tol`` of it, and the
    number of windows.

    Nothing is returned for "is there a beat at all"; see the module
    docstring for why that question has no reliable answer here.

    Windowed tempos are folded by metrical octave before being compared. A
    window heard at double or half time agrees about where the beat is, and
    counting it as disagreement would make every syncopated track look
    unstable.
    """
    import librosa
    try:
        from librosa.feature.rhythm import tempo as _tempo
    except ImportError:                                  # librosa < 0.10
        _tempo = librosa.beat.tempo

    env = np.asarray(onset_env, float)
    n, m = _n_windows(len(env), sr, hop, win_s)
    t = np.array([float(_tempo(onset_envelope=env[i * n:(i + 1) * n], sr=sr,
                               hop_length=hop)[0]) for i in range(m)])

    med = np.median(t)
    folded = t.copy()
    folded[folded < med / 1.5] *= 2
    folded[folded > med * 1.5] /= 2
    tmed = float(np.median(folded))
    agree = float(np.mean(np.abs(folded - tmed) / max(tmed, 1e-9) < tol))

    return {"tempo_bpm": round(tmed, 1),
            "agreement": round(agree, 3) if m > 1 else None,
            "n_windows": m}

Corpus statistics

Corpus-level statistics—the questions per-track tools do not answer.

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 centres 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 centres 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 centres on the circle of fifths."""
    from .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 colours use a fixed, colour-blind-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→colour 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→colour 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 colour 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 colour 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)

draw_tempogram(ax, y, sr, hop=512, mark_bpm=None)

Autocorrelation tempogram onto ax, labelled in BPM.

Bright horizontal bands are the periodicities the onsets actually hold: a band that stays level across the whole width is a steady tempo, and one that bends is a band speeding up or slowing down. A tempo is drawn over it as a dashed line so the two can be compared.

mark_bpm sets which tempo that line shows; the default is this figure's own estimate. Callers quoting a tempo elsewhere on the page should pass theirs, since the two come from different onset envelopes and would otherwise disagree in print.

Source code in src/musiscape/figures.py
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
def draw_tempogram(ax, y, sr, hop: int = 512, mark_bpm: float | None = None):
    """Autocorrelation tempogram onto ``ax``, labelled in BPM.

    Bright horizontal bands are the periodicities the onsets actually hold:
    a band that stays level across the whole width is a steady tempo, and
    one that bends is a band speeding up or slowing down. A tempo is drawn
    over it as a dashed line so the two can be compared.

    ``mark_bpm`` sets which tempo that line shows; the default is this
    figure's own estimate. Callers quoting a tempo elsewhere on the page
    should pass theirs, since the two come from different onset envelopes
    and would otherwise disagree in print.
    """
    from . import music as amusic
    times, bpm, T, t_est = amusic.tempogram(y, sr, hop=hop)
    keep = (bpm >= BPM_RANGE[0]) & (bpm <= BPM_RANGE[1])
    b = bpm[keep]
    im = ax.pcolormesh(times, b, T[keep], shading="auto", cmap="magma",
                       rasterized=True)
    t_est = float(mark_bpm) if mark_bpm else t_est
    ax.axhline(t_est, color="#ffffff", ls="--", lw=1.0, alpha=0.8)
    ax.text(times[-1], t_est, f" {t_est:.0f} BPM ", color="#ffffff",
            fontsize=8, va="center", ha="right",
            bbox=dict(fc="#00000066", ec="none", pad=1.5))
    # A log tempo axis spaces musically equal steps equally, so 60 to 120
    # covers the same distance as 120 to 240. Its automatic ticks would
    # label the axis "3 x 10^2", so the BPM ticks are fixed and the minor
    # ticks are off.
    ax.set_yscale("log")
    ticks = [40, 60, 80, 100, 120, 160, 200, 240]
    ax.set_yticks(ticks)
    ax.set_yticklabels([str(v) for v in ticks])
    ax.minorticks_off()
    ax.set_ylim(*BPM_RANGE)
    ax.set_ylabel("tempo (BPM)", color=MUT, fontsize=9)
    _time_axis(ax)
    _style(ax)
    return im

draw_chromagram(ax, y, sr, hop=512)

Chromagram onto ax, labelled with the twelve pitch classes.

A tonal centre reads as one or two rows staying lit across the width; a modulation moves that pattern bodily up or down the axis.

Source code in src/musiscape/figures.py
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
def draw_chromagram(ax, y, sr, hop: int = 512):
    """Chromagram onto ``ax``, labelled with the twelve pitch classes.

    A tonal centre reads as one or two rows staying lit across the width;
    a modulation moves that pattern bodily up or down the axis.
    """
    from . import music as amusic
    times, C = amusic.chromagram(y, sr, hop=hop)
    im = ax.pcolormesh(times, np.arange(13) - 0.5,
                       np.vstack([C, C[-1:]]), shading="auto", cmap="magma",
                       rasterized=True)
    ax.set_yticks(np.arange(12))
    ax.set_yticklabels(PITCH_CLASSES)
    ax.set_ylim(-0.5, 11.5)
    ax.set_ylabel("pitch class", color=MUT, fontsize=9)
    _time_axis(ax)
    _style(ax)
    return im

tempogram_plot(y, sr, out_path, width_px=1920, height_px=640, title='')

Labelled tempogram → out_path, exactly width_px wide.

Source code in src/musiscape/figures.py
216
217
218
219
220
221
222
223
224
225
226
def tempogram_plot(y, sr, out_path, width_px: int = 1920,
                   height_px: int = 640, title: str = ""):
    """Labelled tempogram → ``out_path``, exactly ``width_px`` wide."""
    fig, ax = plt.subplots()
    im = draw_tempogram(ax, y, sr)
    fig.colorbar(im, ax=ax, pad=0.01).set_label("onset autocorrelation",
                                                color=MUT, fontsize=8)
    if title:
        ax.set_title(title, color=INK, fontsize=11, loc="left")
    fig.tight_layout()
    return _export(fig, out_path, width_px, height_px)

chromagram_plot(y, sr, out_path, width_px=1920, height_px=640, title='')

Labelled chromagram → out_path, exactly width_px wide.

Source code in src/musiscape/figures.py
229
230
231
232
233
234
235
236
237
238
239
def chromagram_plot(y, sr, out_path, width_px: int = 1920,
                    height_px: int = 640, title: str = ""):
    """Labelled chromagram → ``out_path``, exactly ``width_px`` wide."""
    fig, ax = plt.subplots()
    im = draw_chromagram(ax, y, sr)
    fig.colorbar(im, ax=ax, pad=0.01).set_label("pitch-class energy",
                                                color=MUT, fontsize=8)
    if title:
        ax.set_title(title, color=INK, fontsize=11, loc="left")
    fig.tight_layout()
    return _export(fig, out_path, width_px, height_px)

draw_concert_timeline(ax, spans, total_s, level=None)

Labelled timeline of a concert's regions onto ax.

spans is what :func:musiscape.concert.regions returns. With level, a per-second dB array, the class colour is carried by the waveform itself rather than by a separate ribbon: one lane reads faster, and it shows an applause swell dying away where a block only shows that applause happened. Without a level, spans are drawn as plain blocks.

The legend names only the classes that occur, since an entry for a class that never happens invites the reader to hunt for it.

Source code in src/musiscape/figures.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
def draw_concert_timeline(ax, spans, total_s: float, level=None):
    """Labelled timeline of a concert's regions onto ``ax``.

    ``spans`` is what :func:`musiscape.concert.regions` returns. With
    ``level``, a per-second dB array, the class colour is carried by the
    waveform itself rather than by a separate ribbon: one lane reads faster,
    and it shows an applause swell dying away where a block only shows that
    applause happened. Without a level, spans are drawn as plain blocks.

    The legend names only the classes that occur, since an entry for a class
    that never happens invites the reader to hunt for it.
    """
    seen = [s["label"] for s in spans]
    seen = [c for i, c in enumerate(seen) if c not in seen[:i]]

    if level is not None and len(level):
        lv = np.asarray(level, float)
        t = np.linspace(0, total_s, len(lv))
        lo, hi = np.percentile(lv, 2), np.percentile(lv, 98)
        amp = np.clip((lv - lo) / max(hi - lo, 1e-9), 0, 1)
        for span in spans:
            # one sample of overlap each side, so neighbouring bands meet
            m = (t >= span["start_s"]) & (t <= span["end_s"])
            idx = np.flatnonzero(m)
            if not len(idx):
                continue
            a = max(idx[0] - 1, 0)
            b = min(idx[-1] + 2, len(t))
            sl = slice(a, b)
            ax.fill_between(
                t[sl], -amp[sl], amp[sl], linewidth=0,
                color=REGION_COLORS.get(span["label"], REGION_COLORS["other"]))
        ax.set_ylim(-1.08, 1.08)
    else:
        for span in spans:
            ax.axvspan(
                span["start_s"], span["end_s"], 0.0, 1.0, linewidth=0,
                color=REGION_COLORS.get(span["label"], REGION_COLORS["other"]))
        ax.set_ylim(0, 1)

    from matplotlib.patches import Patch
    order = [c for c in REGION_COLORS if c in seen]
    ax.legend(handles=[Patch(facecolor=REGION_COLORS[c], label=c)
                       for c in order],
              loc="upper left", bbox_to_anchor=(0, -0.18), ncol=len(order),
              frameon=False, fontsize=9)

    ax.set_xlim(0, total_s)
    ax.set_yticks([])
    _time_axis(ax, "concert time (m:ss)")
    for side in ("top", "right", "left"):
        ax.spines[side].set_visible(False)
    ax.spines["bottom"].set_color(GRID)
    ax.tick_params(colors=MUT, labelsize=9)
    return ax

concert_timeline(spans, total_s, out_path, width_px=1920, height_px=300, title='', level=None)

Concert timeline → out_path, exactly width_px wide.

Source code in src/musiscape/figures.py
314
315
316
317
318
319
320
321
322
def concert_timeline(spans, total_s: float, out_path, width_px: int = 1920,
                     height_px: int = 300, title: str = "", level=None):
    """Concert timeline → ``out_path``, exactly ``width_px`` wide."""
    fig, ax = plt.subplots()
    draw_concert_timeline(ax, spans, total_s, level=level)
    if title:
        ax.set_title(title, color=INK, fontsize=12, loc="left")
    fig.tight_layout()
    return _export(fig, out_path, width_px, height_px)

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 colour: 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), coloured start → end;
  • keyscape—Sapp-style triangle: every analysis window at every time scale coloured 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 coloured 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-coloured 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), coloured start → end;
  • arcs—Shape-of-Song-style arc diagram: repeated sections found in the self-similarity structure joined by arcs over the timeline.

  • stereo—the stereo field: a pan-by-frequency spectrogram (blue = left, red = right, ink strength = energy) with a goniometer inset, over a width-and-correlation timeline. For multichannel and ambisonic spatial analysis see the ambiscape toolbox;

  • tarsom—the track's position on Schaeffer's seven morphological criteria (TARSOM: masse, timbre harmonique, grain, allure, dynamique, profil mélodique, profil de masse) as a centre–periphery rose: each criterion a sector radiating from the centre pole (tonic, dark, smooth, slow, percussive, static, fixed) toward its periphery pole (complex, bright, granular, fast, soft, mobile, evolving);
  • 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 musiscape.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 colour 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 colours.

Source code in src/musiscape/thumbnails.py
86
87
88
89
90
91
92
93
def barcode_rgb(C, rms):
    """RGB strip (n×3) from 2 Hz chroma + RMS—the barcode's colours."""
    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 colours.

Source code in src/musiscape/thumbnails.py
 96
 97
 98
 99
100
101
102
103
104
105
106
def wave_colors(y, sr, cols=1200):
    """Amplitude envelope + turbo-mapped spectral-centroid colours."""
    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
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
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
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
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")
        if style == "stereo":
            panels = [("stereo_pan", None, None),
                      ("stereo_width", None, None)]
        else:
            panels = (_panels(y, sr, style) if spectro
                      else [(style, None, None)])
        has_strip = style != "wave"
        y2 = None
        if style == "stereo":
            from .io import load_stereo
            y2, _ = load_stereo(track, sr=sr)
        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}
        if spectro:
            ratios = [heights[n] for n, _, _ in panels]
        elif style == "stereo":
            ratios = [4.0, 1.3]
        else:
            ratios = [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", "tarsom")
            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, name if style == "stereo" else style,
                           color=color, y2=y2)
            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 in ("vinyl", "stereo"):
                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
742
743
744
745
746
747
748
749
750
751
752
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
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
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"{_sheet_stem(a.name)}.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 colour strip; style="vinyl" lays the tracks out as a grid of disc glyphs. Albums read as colour families either way. → <out_dir>/poster.png

Source code in src/musiscape/thumbnails.py
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
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 colour strip; ``style="vinyl"`` lays the tracks
    out as a grid of disc glyphs. Albums read as colour 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 when tempo_bpm is absent.

Source code in src/musiscape/thumbnails.py
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
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`` when ``tempo_bpm`` is absent.
    """
    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

Sonic thumbnails

Sonic thumbnails: a short audio summary of each track.

Where the visual thumbnails answer "what does this piece look like", the sonic thumbnail answers "what does it sound like" in ~12 seconds: a montage of up to three segments chosen deterministically from the track's own structure—the most representative passage (the window whose features are closest to the whole track, in the audio-thumbnailing tradition of Bartsch & Wakefield), the climax (peak energy), and the most contrasting section that still carries energy. Segments are placed in chronological order and joined with equal-power crossfades, so the summary preserves the piece's own dramaturgy.

Everything is explainable: no learned model decides what matters.

sonic_thumbnail(y, sr, n_segments=3, seg_s=SEG_S, fade_s=FADE_S)

A ~12 s audio summary montage of y (mono float array).

Source code in src/musiscape/sonic.py
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
def sonic_thumbnail(y, sr, n_segments=3, seg_s=SEG_S,
                    fade_s=FADE_S) -> np.ndarray:
    """A ~12 s audio summary montage of ``y`` (mono float array)."""
    starts = _select_segments(y, sr, n_segments=n_segments, seg_s=seg_s)
    nf = int(fade_s * sr)
    fade_in = np.sin(np.linspace(0, np.pi / 2, nf)) ** 2
    segs = []
    for t0 in starts:
        a = int(t0 * sr)
        seg = y[a:a + int(seg_s * sr)].copy()
        if len(seg) < nf * 2:
            continue
        seg[:nf] *= fade_in
        seg[-nf:] *= fade_in[::-1]
        segs.append(seg)
    if not segs:
        return y[: int(seg_s * sr)]
    out = segs[0]
    for seg in segs[1:]:
        head, tail = out[:-nf], out[-nf:]
        out = np.concatenate([head, tail + seg[:nf], seg[nf:]])
    peak = np.abs(out).max() + 1e-9
    return (out / peak * 0.89).astype(np.float32)

export_collection(coll, out_dir, workers=4, sr=22050)

Sonic thumbnails for every track → <out_dir>/sonic/<album>/, plus one concatenated medley file per album.

Source code in src/musiscape/sonic.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
def export_collection(coll: Collection, out_dir: str | Path,
                      workers: int = 4, sr: int = 22050) -> Path:
    """Sonic thumbnails for every track → ``<out_dir>/sonic/<album>/``,
    plus one concatenated medley file per album."""
    import soundfile as sf
    out_dir = Path(out_dir) / "sonic"
    jobs = [(str(t.path), t.album,
             out_dir / t.album / f"{t.title}.wav", sr) 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))
    gap = np.zeros(int(0.5 * sr), dtype=np.float32)
    for a in coll.albums:
        parts = []
        for t in a.tracks:
            f = out_dir / a.name / f"{t.title}.wav"
            if f.exists():
                parts += [sf.read(f, dtype="float32")[0], gap]
        if parts:
            sf.write(out_dir / f"{album_stem(a.name)} medley.wav",
                     np.concatenate(parts[:-1]), sr)
    return out_dir

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 to 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

PDF report

One PDF: a summary table, then a page of figures per track.

The Markdown report in :mod:report is for reading on screen next to the audio. This is the one to hand someone: a front table that fits an entire concert on a page, and behind it one page per track carrying the figures the table's numbers came from.

Every estimate is printed with its cross-check beside it rather than alone. key and tempo_bpm each travel with the share of 20-second windows that agreed on them. That is the whole point of the layout: a number in this report is never presented as more certain than it is, and the reader can see which tracks the analysis is confident about without knowing anything about how it works.

No column claims to say whether a track has a pulse, because no measure here distinguishes a band from an audience clapping along. See :mod:stability.

Written with matplotlib's PdfPages, so no PDF library is needed beyond what the package already depends on.

confidence(agreement)

Word for a window-agreement share, or a dash when unmeasured.

Source code in src/musiscape/pdfreport.py
41
42
43
44
45
46
47
48
49
def confidence(agreement: float | None) -> str:
    """Word for a window-agreement share, or a dash when unmeasured."""
    if agreement is None:
        return "—"
    if agreement >= STRONG:
        return "strong"
    if agreement >= WEAK:
        return "fair"
    return "weak"

build(coll, out_dir, workers=4, duration=None, title=None)

Summary table + one figure page per track → <out_dir>/report.pdf.

Features are extracted (and cached) exactly as every other verb does, so running this after report costs only the drawing.

Source code in src/musiscape/pdfreport.py
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
def build(coll, out_dir: str | Path, workers: int = 4,
          duration: float | None = None, title: str | None = None) -> Path:
    """Summary table + one figure page per track → ``<out_dir>/report.pdf``.

    Features are extracted (and cached) exactly as every other verb does,
    so running this after ``report`` costs only the drawing.
    """
    from matplotlib.backends.backend_pdf import PdfPages

    from . import features as afeat
    from .io import load

    out_dir = Path(out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)
    fpath = afeat.extract_collection(coll, out_dir, workers=workers,
                                     duration=duration)
    feats = afeat.load_features(fpath)
    by_title = {f["track"]: f for f in feats}
    tracks = [t for t in coll.tracks if t.title in by_title]
    ordered = [by_title[t.title] for t in tracks]

    pdf_path = out_dir / "report.pdf"
    with PdfPages(pdf_path) as pdf:
        _summary_page(pdf, ordered, title or coll.root.name)
        for i, track in enumerate(tracks, start=1):
            y, sr = load(track, duration=duration)
            _track_page(pdf, y, sr, by_title[track.title], i)
    return pdf_path

Command line

The verbs and options are in Command line; this is the entry point itself.

Command line: musiscape <verb> <collection-folder>.

main(argv=None)

Entry point for the musiscape command.

Source code in src/musiscape/cli.py
 16
 17
 18
 19
 20
 21
 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
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
def main(argv=None):
    """Entry point for the ``musiscape`` command."""
    p = argparse.ArgumentParser(
        prog="musiscape",
        description="Analyse a music collection: corpus fingerprints, "
                    "similarity landscape, honest categories.")
    p.add_argument("verb", choices=["probe", "extract", "fingerprint",
                                    "landscape", "categorize", "report",
                                    "thumbnails", "poster", "sonic",
                                    "segment", "figures", "pdf", "timecourse", "transcribe"])
    p.add_argument("folder", help="collection root (albums = subfolders); "
                                  "for segment, timecourse and transcribe, a folder of recordings")
    p.add_argument("-o", "--out", help="output folder (default <root>/analysis)")
    p.add_argument("--workers", type=int, default=4)
    p.add_argument("--duration", type=float,
                   help="analyse only the first N seconds per track")
    p.add_argument("-k", type=int, help="number of categories (default: auto)")
    p.add_argument("--style", default="mel",
                   help="thumbnail style: mel|chroma|tempo|combo|barcode|"
                        "ssm|trajectory|keyscape|rhythm|wave|vinyl|spiral|tonnetz|arcs|schaeffer|tarsom|stereo (default mel); poster accepts barcode|vinyl")
    p.add_argument("--min-song", type=float, default=60.0,
                   help="segment: shortest span counted as a song (s)")
    p.add_argument("--min-gap", type=float, default=12.0,
                   help="segment: shortest break that ends a song (s)")
    p.add_argument("--width", type=int, default=1920,
                   help="figures: export width in pixels (default 1920)")
    p.add_argument("--method", default="heuristic", choices=["heuristic", "panns"],
                   help="segment: region classifier; panns needs ambiscape[ml]")
    p.add_argument("--device", default=None,
                   help="segment --method panns: cpu, cuda or auto (default: ambiscape's, cpu)")
    p.add_argument("--setlist", default=None,
                   help="segment: running order (.docx table or .json) to align the songs to")
    args = p.parse_args(argv)

    # transcribe: piano note events per recording (optional extra), and the notes folded per second.
    if args.verb == "transcribe":
        import csv
        from . import transcribe as trm
        from .io import list_recordings, load_recording
        root = Path(args.folder).expanduser().resolve()
        out = (Path(args.out) if args.out else root / "analysis").expanduser().resolve()
        out.mkdir(parents=True, exist_ok=True)
        for path in list_recordings(root, exclude=[out]):
            y, sr = load_recording(path, sr=trm.MODEL_SR, duration=args.duration)
            notes = trm.transcribe_piano(y, sr, midi_path=str(out / f"{path.stem}_notes.mid"))
            notes.to_csv(out / f"{path.stem}_notes.csv", index=False, float_format="%.4f")
            per = trm.notes_per_second(notes, len(y) / sr)
            with open(out / f"{path.stem}_notes_1hz.csv", "w", newline="") as fh:
                w = csv.writer(fh); w.writerow(["t", "density", "pitch_mean", "velocity_mean", "pitch_spread", "sustain"])
                for i in range(len(per["t"])):
                    w.writerow([per["t"][i], per["density"][i], per["pitch_mean"][i], per["velocity_mean"][i], per["pitch_spread"][i], per["sustain"][i]])
            print(f"{path.stem}: {len(notes)} notes, {len(notes) / (len(y) / sr / 60):.0f}/min -> {out}")
        return

    # timecourse also takes a folder of recordings: one CSV, one section table and three
    # figures per file, at one row per second.
    if args.verb == "timecourse":
        import csv
        from . import timecourse as tcm
        from .io import list_recordings, load_recording
        root = Path(args.folder).expanduser().resolve()
        out = (Path(args.out) if args.out else root / "analysis").expanduser().resolve()
        out.mkdir(parents=True, exist_ok=True)
        for path in list_recordings(root, exclude=[out]):
            y, sr = load_recording(path, sr=22050, duration=args.duration)
            tc = tcm.music_timecourse(y, sr)
            stem = path.stem
            with open(out / f"{stem}_timecourse.csv", "w", newline="") as fh:
                w = csv.writer(fh)
                w.writerow(["t", "rms", "local_tempo_bpm", "pulse_clarity", "chroma_entropy", "tonal_clarity", "hcdf",
                            "centroid", "flatness", "harmonic_ratio", "register_midi", "register_spread", "timbre_novelty"]
                           + [f"chroma_{n}" for n in tcm.KEY_NAMES])
                for i in range(len(tc["t"])):
                    w.writerow([tc["t"][i], tc["rms"][i], tc["local_tempo"][i], tc["pulse_clarity"][i], tc["chroma_entropy"][i],
                                tc["tonal_clarity"][i], tc["hcdf"][i], tc["centroid"][i], tc["flatness"][i], tc["harmonic_ratio"][i],
                                tc["register_midi"][i], tc["register_spread"][i], tc["timbre_novelty"][i]] + list(tc["chroma"][:, i]))
            with open(out / f"{stem}_keys.csv", "w", newline="") as fh:
                w = csv.writer(fh); w.writerow(["t", "key", "r"]); w.writerows(tc["keys"])
            tcm.timecourse_figures(tc, out, prefix=f"{stem}_", title=stem)
            print(f"{stem}: {len(tc['t'])} s, keys {len(tc['keys'])} windows -> {out}")
        return

    # segment runs before the collection is opened: a folder of camera
    # files holds no audio files at all, and open_collection would refuse
    # it. Its output folder is where a collection then comes from.
    if args.verb == "segment":
        from . import concert
        from .io import list_recordings, recording_start_time
        root = Path(args.folder).expanduser().resolve()
        out = (Path(args.out) if args.out else root / "analysis")
        out = out.expanduser().resolve()
        paths = list_recordings(root, exclude=[out])
        manifest = concert.split_recording(paths, out,
                                           min_song_s=args.min_song,
                                           min_gap_s=args.min_gap)
        songs = json.loads(manifest.read_text())

        # what the recording was doing all evening, songs included
        from . import figures as afig
        rmap = concert.map_regions(paths, songs=songs, method=args.method, device=args.device)
        (out / "regions.json").write_text(json.dumps(rmap["spans"], indent=1))
        if args.setlist:
            from . import setlist as sl
            acts = sl.load_setlist(args.setlist)
            pieces = [{"id": f"song-{s['index']}", "intro": None} for s in songs]
            al = sl.align_setlist(pieces, acts)          # no transcripts here: running order decides
            for s in songs:
                j = al["assignments"][f"song-{s['index']}"]
                s["setlist"] = None if j is None else {**acts[j], "title": sl.act_title(acts[j]),
                                                         "match": al["how"][f"song-{s['index']}"]}
            (out / "setlist.json").write_text(json.dumps(
                {"acts": acts, "assignments": al["assignments"], "how": al["how"],
                 "not_detected": [acts[j] for j in al["not_detected"]]}, indent=1, ensure_ascii=False))
            manifest.write_text(json.dumps(songs, indent=1, ensure_ascii=False))
        afig.concert_timeline(rmap["spans"], rmap["total_s"],
                              out / "timeline.png", width_px=args.width,
                              title=root.name, level=rmap["level_db"])
        concert.export_regions(paths, out / "other", rmap["spans"],
                               start_time=recording_start_time(paths[0]))
        tally = {}
        for sp in rmap["spans"]:
            tally[sp["label"]] = tally.get(sp["label"], 0.0) + sp["duration_s"]

        for s in songs:
            t = int(s["start_s"])
            across = (f" across {len(s['parts'])} files"
                      if len(s["parts"]) > 1 else "")
            print(f"{s['index']:2d}. {t // 60:3d}:{t % 60:02d} "
                  f"{s['duration_s'] / 60:5.1f} min  {s['file']}{across}")
        print(f"{len(songs)} songs from {len(paths)} recording(s) → "
              f"{manifest.parent / 'songs'}")
        print("regions: " + ", ".join(
            f"{k} {v / 60:.1f} min" for k, v in sorted(
                tally.items(), key=lambda kv: -kv[1])))
        print(f"{out / 'timeline.png'}")
        return

    coll = open_collection(args.folder)
    out = _out(args, coll)

    if args.verb == "sonic":
        from . import sonic
        print(sonic.export_collection(coll, out, workers=args.workers))
        return

    if args.verb == "poster":
        from . import thumbnails
        pstyle = "vinyl" if args.style == "vinyl" else "barcode"
        print(thumbnails.poster(coll, out, workers=args.workers,
                                style=pstyle))
        return

    if args.verb == "thumbnails":
        from . import thumbnails
        notes = {}
        fpath = out / "features.json"
        if fpath.exists():
            notes = thumbnails.notes_from_features(
                features.load_features(fpath))
        print(thumbnails.render_collection(coll, out, notes=notes,
                                           workers=args.workers,
                                           style=args.style))
        return

    if args.verb == "figures":
        from . import figures as afig
        from .io import load
        fdir = out / "figures"
        for t in coll.tracks:
            y, sr = load(t, duration=args.duration)
            stem = f"{t.album.replace('/', '_')}_{t.title}".lstrip("._")
            afig.chromagram_plot(y, sr, fdir / f"{stem} chromagram.png",
                                 width_px=args.width, title=t.title)
            afig.tempogram_plot(y, sr, fdir / f"{stem} tempogram.png",
                                width_px=args.width, title=t.title)
            print(f"[{t.album}] {t.title}", flush=True)
        print(fdir)
        return

    if args.verb == "pdf":
        from . import pdfreport
        print(pdfreport.build(coll, out, workers=args.workers,
                              duration=args.duration))
        return

    if args.verb == "probe":
        for a in coll.albums:
            mins = "?"
            print(f"{a.name}: {len(a.tracks)} tracks")
            for t in a.tracks[:50]:
                print(f"  {t.title}")
        print(f"total: {len(coll.tracks)} tracks in {len(coll.albums)} albums")
        return

    fpath = features.extract_collection(coll, out, workers=args.workers,
                                        duration=args.duration)
    feats = features.load_features(fpath)

    if args.verb == "extract":
        print(f"{len(feats)} tracks → {fpath}")
    elif args.verb == "fingerprint":
        from . import figures
        stats = corpus.album_stats(feats)
        figures.fingerprints(stats, out / "fingerprints.png",
                             title=coll.root.name)
        (out / "album_stats.json").write_text(json.dumps(stats, indent=1))
        print(out / "fingerprints.png")
    elif args.verb == "landscape":
        from . import figures
        land = corpus.landscape(feats)
        figures.landscape_plot(feats, land, out / "landscape.png")
        sim = corpus.similarity(feats)
        figures.affinity_plot(sim["affinity"], out / "affinity.png")
        print(out / "landscape.png")
    elif args.verb == "categorize":
        cats = categorize.cluster(feats, k=args.k)
        (out / "categories.json").write_text(json.dumps(cats, indent=1))
        for i, c in enumerate(cats["clusters"]):
            sig = ", ".join(f"{k} {v:+.1f}" for k, v in c["signature"].items())
            print(f"category {i + 1} ({c['size']}): {sig}")
    elif args.verb == "report":
        print(report.run(coll, out, workers=args.workers,
                         duration=args.duration, k=args.k))

Concert segmentation from AudioSet tags

Concert segmentation from AudioSet posteriors (PANNs through ambiscape).

:mod:musiscape.concert labels a concert from spectral flatness and level, which costs nothing per frame and was calibrated on one camera-mic concert. It mistakes loud rock for applause and hears noise music as "other". This module does the same job from a sound-event tagger: ambiscape.ml.tag_frames gives AudioSet posteriors every couple of seconds, and the chain below turns them into the same spans vocabulary (music, applause, voices, quiet, other) so :func:musiscape.figures.concert_timeline and :func:musiscape.concert.export_regions work unchanged.

Needs ambiscape[ml] (PANNs, torch). Roughly a minute for a 90-minute concert on a laptop GPU (device="auto"), half an hour on CPU.

tag_frames(y, sr, win_s=4.0, hop_s=2.0, device=None)

(times, probs, names) from ambiscape.ml.tag_frames; a clear error without the extra.

Source code in src/musiscape/tagging.py
43
44
45
46
47
48
49
50
def tag_frames(y: np.ndarray, sr: int, win_s: float = 4.0, hop_s: float = 2.0, device=None):
    """``(times, probs, names)`` from ``ambiscape.ml.tag_frames``; a clear error without the extra."""
    try:
        from ambiscape import ml
    except ImportError as e:  # pragma: no cover
        raise ImportError("musiscape.tagging needs ambiscape with the [ml] extra "
                          "(pip install 'ambiscape[ml]')") from e
    return ml.tag_frames(np.asarray(y, dtype=np.float32), sr, win_s=win_s, hop_s=hop_s, device=device)

frame_level_db(y, sr, times, win_s)

RMS in dBFS of the window centred on each time.

Source code in src/musiscape/tagging.py
53
54
55
56
57
58
59
60
61
def frame_level_db(y: np.ndarray, sr: int, times: np.ndarray, win_s: float) -> np.ndarray:
    """RMS in dBFS of the window centred on each time."""
    out = np.full(len(times), -99.0)
    half = int(win_s * sr / 2)
    for k, t in enumerate(times):
        a, b = max(0, int(t * sr) - half), min(len(y), int(t * sr) + half)
        if b > a:
            out[k] = 20 * np.log10(np.sqrt(np.mean(np.asarray(y[a:b], dtype=np.float64) ** 2)) + 1e-9)
    return out

group_scores(P, names, groups=GROUPS)

Max posterior over each group's labels, per frame. Labels missing from names are ignored.

Source code in src/musiscape/tagging.py
64
65
66
67
68
69
70
71
def group_scores(P: np.ndarray, names: list[str], groups: dict[str, list[str]] = GROUPS) -> dict[str, np.ndarray]:
    """Max posterior over each group's labels, per frame. Labels missing from ``names`` are ignored."""
    ix = {n: i for i, n in enumerate(names)}
    out = {}
    for g, labs in groups.items():
        cols = [ix[l] for l in labs if l in ix]
        out[g] = P[:, cols].max(axis=1) if cols else np.zeros(len(P))
    return out

decide_frames(scores, level_db, weights=WEIGHTS, quiet_db=QUIET_DBFS, other_floor=OTHER_FLOOR)

One region label per frame: weighted argmax, level gate for quiet, floor for other.

Source code in src/musiscape/tagging.py
74
75
76
77
78
79
80
81
82
def decide_frames(scores: dict[str, np.ndarray], level_db: np.ndarray, weights: dict = WEIGHTS,
                  quiet_db: float = QUIET_DBFS, other_floor: float = OTHER_FLOOR) -> np.ndarray:
    """One region label per frame: weighted argmax, level gate for ``quiet``, floor for ``other``."""
    kinds = [k for k in ("music", "voices", "applause", "quiet") if k in scores]
    W = np.stack([scores[k] * weights.get(k, 1.0) for k in kinds], axis=1)
    lab = np.array([kinds[i] for i in W.argmax(axis=1)], dtype=object)
    lab[W.max(axis=1) < other_floor] = "other"
    lab[np.asarray(level_db) < quiet_db] = "quiet"
    return lab

mode_filter(lab, width=SMOOTH_FRAMES)

Sliding majority vote; a tie keeps the centre label.

Source code in src/musiscape/tagging.py
85
86
87
88
89
90
91
92
93
94
95
def mode_filter(lab: np.ndarray, width: int = SMOOTH_FRAMES) -> np.ndarray:
    """Sliding majority vote; a tie keeps the centre label."""
    if width <= 1:
        return lab.copy()
    h, n, out = width // 2, len(lab), lab.copy()
    for i in range(n):
        w = lab[max(0, i - h): min(n, i + h + 1)]
        vals, counts = np.unique(w, return_counts=True)
        winners = set(vals[counts == counts.max()])
        out[i] = lab[i] if lab[i] in winners else vals[counts.argmax()]
    return out

runs_to_spans(lab, hop_s, win_s, total_s, scores=None)

Run-length encode frame labels into contiguous spans covering [0, total_s].

Source code in src/musiscape/tagging.py
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
def runs_to_spans(lab: np.ndarray, hop_s: float, win_s: float, total_s: float,
                  scores: dict[str, np.ndarray] | None = None) -> list[dict]:
    """Run-length encode frame labels into contiguous spans covering ``[0, total_s]``."""
    spans, n, i = [], len(lab), 0
    off = (win_s - hop_s) / 2.0        # frame i is centred at i*hop + win/2 and owns +-hop/2
    while i < n:
        j = i
        while j + 1 < n and lab[j + 1] == lab[i]:
            j += 1
        start = 0.0 if i == 0 else i * hop_s + off
        end = total_s if j == n - 1 else (j + 1) * hop_s + off
        conf = float(np.mean(scores[lab[i]][i:j + 1])) if scores and lab[i] in scores else 0.0
        spans.append(_span(lab[i], start, end, conf))
        i = j + 1
    return spans

enforce_min_duration(spans, min_s=MIN_DURATION_S)

Absorb spans shorter than their class minimum into the longer neighbour, shortest first, until stable.

Source code in src/musiscape/tagging.py
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
def enforce_min_duration(spans: list[dict], min_s: dict[str, float] = MIN_DURATION_S) -> list[dict]:
    """Absorb spans shorter than their class minimum into the longer neighbour, shortest first, until stable."""
    spans = _merge_adjacent(spans)
    changed = True
    while changed and len(spans) > 1:
        changed = False
        for k in sorted(range(len(spans)), key=lambda k: spans[k]["duration_s"]):
            s = spans[k]
            if s["duration_s"] >= min_s.get(s["label"], 0.0):
                continue
            prev = spans[k - 1] if k > 0 else None
            nxt = spans[k + 1] if k + 1 < len(spans) else None
            if prev is None and nxt is None:
                break
            target = prev if (nxt is None or (prev is not None and prev["duration_s"] >= nxt["duration_s"])) else nxt
            if target is prev:
                prev["end_s"] = s["end_s"]; prev["duration_s"] = prev["end_s"] - prev["start_s"]
            else:
                nxt["start_s"] = s["start_s"]; nxt["duration_s"] = nxt["end_s"] - nxt["start_s"]
            del spans[k]
            spans = _merge_adjacent(spans)
            changed = True
            break
    return spans

absorb_other(spans)

other next to music and not next to voices is part of the performance.

Noise music, laptop pieces and extended techniques score low on "Music" and come out as loud sound that is neither speech, applause nor silence; between or beside music, that is the piece.

Source code in src/musiscape/tagging.py
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
def absorb_other(spans: list[dict]) -> list[dict]:
    """``other`` next to music and not next to voices is part of the performance.

    Noise music, laptop pieces and extended techniques score low on "Music" and come out as
    loud sound that is neither speech, applause nor silence; between or beside music, that is
    the piece."""
    out = [dict(s) for s in spans]
    for k, s in enumerate(out):
        if s["label"] != "other":
            continue
        prev = out[k - 1]["label"] if k > 0 else None
        nxt = out[k + 1]["label"] if k + 1 < len(out) else None
        if "music" in (prev, nxt) and "voices" not in (prev, nxt):
            s["label"] = "music"
    return _merge_adjacent(out)

snap_to_songs(spans, songs, snap_s=SNAP_S)

Move music edges onto :func:concert.find_songs boundaries when the two agree within snap_s.

Source code in src/musiscape/tagging.py
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
def snap_to_songs(spans: list[dict], songs: list[dict], snap_s: float = SNAP_S) -> list[dict]:
    """Move music edges onto :func:`concert.find_songs` boundaries when the two agree within ``snap_s``."""
    out = [dict(s) for s in spans]
    for k, s in enumerate(out):
        if s["label"] != "music":
            continue
        for song in songs:
            if abs(song["start_s"] - s["start_s"]) <= snap_s:
                s["start_s"] = float(song["start_s"])
                if k > 0:
                    out[k - 1]["end_s"] = s["start_s"]
            if abs(song["end_s"] - s["end_s"]) <= snap_s:
                s["end_s"] = float(song["end_s"])
                if k + 1 < len(out):
                    out[k + 1]["start_s"] = s["end_s"]
    for s in out:
        s["duration_s"] = s["end_s"] - s["start_s"]
    return [s for s in out if s["duration_s"] > 0]

refine_music_onsets(spans, y, sr, look_back_s=10.0, rise_db=8.0, frame_s=0.1)

Move each music span's start back to where the sound actually begins.

Tag windows are seconds long and the majority filter is longer, so a detected start trails the first note by a few seconds. Within look_back_s before the detected start, the onset is the earliest frame from which the level stays rise_db above the floor of that window until the detected start.

Source code in src/musiscape/tagging.py
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
def refine_music_onsets(spans: list[dict], y: np.ndarray, sr: int, look_back_s: float = 10.0,
                        rise_db: float = 8.0, frame_s: float = 0.1) -> list[dict]:
    """Move each music span's start back to where the sound actually begins.

    Tag windows are seconds long and the majority filter is longer, so a detected start
    trails the first note by a few seconds. Within ``look_back_s`` before the detected start,
    the onset is the earliest frame from which the level stays ``rise_db`` above the floor
    of that window until the detected start."""
    out = [dict(s) for s in spans]
    hop = max(1, int(frame_s * sr))
    for k, s in enumerate(out):
        if s["label"] != "music" or k == 0:
            continue
        a = max(0, int((s["start_s"] - look_back_s) * sr)); b = min(len(y), int((s["start_s"] + 2.0) * sr))
        if b - a < 5 * hop:
            continue
        seg = np.asarray(y[a:b], dtype=np.float64)
        n = len(seg) // hop
        lv = 20 * np.log10(np.sqrt((seg[: n * hop].reshape(n, hop) ** 2).mean(axis=1)) + 1e-9)
        floor = np.percentile(lv, 10)
        above = lv > floor + rise_db
        i0 = int(round((s["start_s"] * sr - a) / hop))
        i0 = min(max(i0, 1), n - 1)
        j = i0
        while j > 0 and above[j - 1]:
            j -= 1
        onset = a / sr + j * frame_s
        earliest = out[k - 1]["start_s"] + 1.0
        if onset < s["start_s"] and onset > earliest:
            s["start_s"] = float(onset); out[k - 1]["end_s"] = float(onset)
    for s in out:
        s["duration_s"] = s["end_s"] - s["start_s"]
    return [s for s in out if s["duration_s"] > 0]

segment_concert(y, sr, songs=None, win_s=4.0, hop_s=2.0, device=None, min_s=MIN_DURATION_S, refine_onsets=True)

The whole chain on one mono array.

Returns {"spans": [...], "total_s": float, "frames": {"t", "music", "voices", "applause", "quiet", "level_db"}} with spans in the :data:musiscape.concert.REGION_CLASSES vocabulary, contiguous from 0 to the end.

Source code in src/musiscape/tagging.py
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
def segment_concert(y: np.ndarray, sr: int, songs: list[dict] | None = None, win_s: float = 4.0,
                    hop_s: float = 2.0, device=None, min_s: dict[str, float] = MIN_DURATION_S,
                    refine_onsets: bool = True) -> dict:
    """The whole chain on one mono array.

    Returns ``{"spans": [...], "total_s": float, "frames": {"t", "music", "voices", "applause", "quiet", "level_db"}}``
    with spans in the :data:`musiscape.concert.REGION_CLASSES` vocabulary, contiguous from 0 to the end.
    """
    total_s = len(y) / sr
    t, P, names = tag_frames(y, sr, win_s=win_s, hop_s=hop_s, device=device)
    level = frame_level_db(y, sr, t, win_s)
    scores = group_scores(P, names)
    lab = mode_filter(decide_frames(scores, level))
    spans = runs_to_spans(lab, hop_s, win_s, total_s, scores)
    spans = enforce_min_duration(spans, min_s)
    spans = absorb_other(spans)
    if songs:
        spans = snap_to_songs(spans, songs)
    if refine_onsets:
        spans = refine_music_onsets(spans, y, sr)
    for s in spans:
        sel = (t >= s["start_s"]) & (t < s["end_s"])
        if sel.any() and s["label"] in scores:
            s["confidence"] = float(scores[s["label"]][sel].mean())
        s["confidence"] = round(s["confidence"], 3)
        assert s["label"] in REGION_CLASSES
    frames = {"t": t, "level_db": level, **{k: v for k, v in scores.items()}}
    return {"spans": spans, "total_s": round(total_s, 2), "frames": frames}

Setlist alignment

Aligning detected pieces with the setlist.

A concert's running order says what was planned; the recording says what happened, in what order, and what was dropped. Given the pieces a segmenter found and the text of the spoken introduction before each (from any transcriber), this matches names heard to names planned and fills the rest in running order. Names the host thanks belong to the act that just finished and are ignored; a name right after "vær så god" or "ved" is the act being introduced and counts extra. Acts nobody was matched to come back as not_detected: cancelled, or not a musical number.

The setlist is a JSON list of acts {"nr", "act", "performers", "work", "composer", "contact"} or a .docx whose first table has such columns (the IMV kjøreplan template: Nr. / Innslag / Komponist / Låt / Medvirkende).

load_setlist(path)

Acts from a .json list or the first suitable table of a .docx.

Source code in src/musiscape/setlist.py
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
def load_setlist(path: str | Path) -> list[dict]:
    """Acts from a ``.json`` list or the first suitable table of a ``.docx``."""
    path = Path(path)
    if path.suffix.lower() == ".json":
        return json.loads(path.read_text())
    if path.suffix.lower() != ".docx":
        raise ValueError("setlist must be .json or .docx")
    for rows in _docx_tables(path):
        if not rows:
            continue
        header = [c.strip().lower() for c in rows[0]]
        col = {}
        for key, aliases in HEADER_ALIASES.items():
            for i, h in enumerate(header):
                if h in aliases:
                    col[key] = i
                    break
        if "act" not in col:
            continue
        acts = []
        for r in rows[1:]:
            get = lambda k: r[col[k]].strip() if k in col and col[k] < len(r) else ""
            if not get("act") and not get("performers"):
                continue
            acts.append({k: get(k) for k in ("nr", "act", "composer", "work", "performers", "contact")})
        if acts:
            return acts
    raise ValueError("no table with an act/innslag column found")

thanked_names(text)

Names the host thanks: the previous act. A transcript that is nothing but the thanks is left alone.

Source code in src/musiscape/setlist.py
87
88
89
90
91
92
93
94
def thanked_names(text: str) -> set[str]:
    """Names the host thanks: the previous act. A transcript that is nothing but the thanks is left alone."""
    out = set()
    for m in _THANKS.finditer(text or ""):
        if len(text) - m.end() < 3:
            continue
        out |= {t.lower() for t in _CAP.findall(m.group(1))}
    return out

intro_names(text)

(token, relative position, cue bonus) for the words of an introduction that could be names.

Source code in src/musiscape/setlist.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
def intro_names(text: str | None) -> list[tuple[str, float, float]]:
    """``(token, relative position, cue bonus)`` for the words of an introduction that could be names."""
    if not text:
        return []
    skip = thanked_names(text)
    cue_spans = [(m.end(), m.end() + 45) for m in _CUE.finditer(text)]
    toks = []
    for m in _WORD.finditer(text):
        t = m.group(0).lower()
        if t in skip:
            continue
        bonus = 0.1 if any(a <= m.start() <= b for a, b in cue_spans) else 0.0
        toks.append((t, m.start() / max(1, len(text)), bonus))
    return toks

name_score(intro_text, act)

(score, position): best fuzzy match between the intro and the act; full names count more.

Source code in src/musiscape/setlist.py
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
def name_score(intro_text: str | None, act: dict) -> tuple[float, float]:
    """``(score, position)``: best fuzzy match between the intro and the act; full names count more."""
    names = intro_names(intro_text)
    if not names:
        return 0.0, 0.0
    uni, bi = _plan_tokens(act)
    best, pos = 0.0, 0.0
    for k, (a, p, bonus) in enumerate(names):
        for b in uni:
            r = difflib.SequenceMatcher(None, a, b).ratio()
            if r >= 0.75 and r + bonus > best:
                best, pos = r + bonus, p
        if k + 1 < len(names):
            pair = f"{a} {names[k + 1][0]}"
            for b in bi:
                r = difflib.SequenceMatcher(None, pair, b).ratio()
                if r >= 0.8 and r + 0.1 + bonus > best:
                    best, pos = r + 0.1 + bonus, p
    return best, pos

align_setlist(pieces, acts, min_score=0.8)

Match detected pieces ({"id", "intro": text} in playing order) to acts.

Returns assignments (piece id -> act index or None), how (name / order / continues), not_detected (act indices) and the per-act scores of every piece.

Source code in src/musiscape/setlist.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
def align_setlist(pieces: list[dict], acts: list[dict], min_score: float = 0.8) -> dict:
    """Match detected pieces (``{"id", "intro": text}`` in playing order) to acts.

    Returns ``assignments`` (piece id -> act index or None), ``how`` (``name`` / ``order`` /
    ``continues``), ``not_detected`` (act indices) and the per-act ``scores`` of every piece.
    """
    n, m = len(pieces), len(acts)
    scored = [[name_score(p.get("intro"), a) for a in acts] for p in pieces]
    assign: list[int | None] = [None] * n
    used: set[int] = set()
    cands = sorted(((sc, pos, i, j) for i in range(n) for j in range(m) for sc, pos in [scored[i][j]] if sc >= min_score),
                   key=lambda c: (c[0], c[1]), reverse=True)
    for sc, pos, i, j in cands:                       # confident names; the one said last wins a tie
        if assign[i] is None and j not in used:
            assign[i] = j; used.add(j)
    how = {i: "name" for i in range(n) if assign[i] is not None}
    for i in range(n):                                # leftovers by running order between matched neighbours
        if assign[i] is not None:
            continue
        lo = max([assign[k] for k in range(i) if assign[k] is not None], default=-1)
        hi = min([assign[k] for k in range(i + 1, n) if assign[k] is not None], default=m)
        free = [j for j in range(lo + 1, hi) if j not in used]
        if free:
            assign[i] = free[0]; used.add(free[0]); how[i] = "order"
    for i in range(1, n):                             # nobody named and nothing free: the act continues
        if assign[i] is None and assign[i - 1] is not None and max((sc for sc, _ in scored[i]), default=0.0) < min_score:
            assign[i] = assign[i - 1]; how[i] = "continues"
    return {"assignments": {pieces[i]["id"]: assign[i] for i in range(n)},
            "how": {pieces[i]["id"]: how.get(i) for i in range(n)},
            "not_detected": [j for j in range(m) if j not in set(a for a in assign if a is not None)],
            "scores": {pieces[i]["id"]: [round(sc, 2) for sc, _ in scored[i]] for i in range(n)}}

act_title(act)

"3. Menuett fra Suite op. 20 – Øyvin Dybsand": the work when there is one, else the act name.

Source code in src/musiscape/setlist.py
175
176
177
178
179
180
181
182
183
def act_title(act: dict) -> str:
    """``"3. Menuett fra Suite op. 20 – Øyvin Dybsand"``: the work when there is one, else the act name."""
    work = act.get("work") or ""
    work = work.split(":", 1)[1].strip() if ":" in work else work
    work = " / ".join(w.strip(" ,/") for w in work.split("/") if w.strip(" ,/"))
    label = work if work and work not in ("–", "-", "Ikke bestemt") else act.get("act", "")
    who = act.get("performers") or ""
    title = f"{act.get('nr', '')}. {label}".strip(". ")
    return title + (f" – {who}" if who and who not in ("–", "-") else "")