Skip to content

Audio

Class container for audio analysis processes.

Initializes the MgAudio class.

Parameters:

Name Type Description Default
filename str

Path to the audio file. Passed by the parent MgVideo.

required
sr int

Sampling rate of the audio file. Possible to specify a target sampling rate. Defaults to None (i.e. original sampling rate).

None
n_fft int

Length of the FFT window. Defaults to 2048.

2048
hop_length int

Number of samples between successive frames. Defaults to 512.

512
Source code in musicalgestures/_audio.py
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
def __init__(
        self,
        filename: str,
        sr: int = None,
        n_fft: int = 2048,
        hop_length: int = 512,
        ):
    """
    Initializes the MgAudio class.

    Args:
        filename (str): Path to the audio file. Passed by the parent MgVideo.
        sr (int, optional): Sampling rate of the audio file. Possible to specify a target sampling rate. Defaults to None (i.e. original sampling rate).
        n_fft (int, optional): Length of the FFT window. Defaults to 2048.
        hop_length (int, optional): Number of samples between successive frames. Defaults to 512.
    """

    self.filename = filename
    self.of, self.fex = os.path.splitext(filename)
    if sr is None:
        # ffprobe rather than librosa: librosa 1.0 dropped the audioread fallback,
        # so it can no longer read a sampling rate out of a video container.
        self.sr = get_samplerate(self.filename)
    else:
        self.sr = sr
    self.n_fft = n_fft
    self.hop_length = hop_length
    self.length = get_length(self.filename)
    self._y_cache = None  # cached (y, sr) from librosa.load, keyed by sr

filename instance-attribute

filename = filename

sr instance-attribute

sr = get_samplerate(self.filename)

n_fft instance-attribute

n_fft = n_fft

hop_length instance-attribute

hop_length = hop_length

length instance-attribute

length = get_length(self.filename)

_y_cache instance-attribute

_y_cache = None

duration property

duration

Audio duration in seconds (for an MgAudio this equals self.length).

__repr__

__repr__()
Source code in musicalgestures/_audio.py
57
58
59
60
def __repr__(self) -> str:
    dur = getattr(self, 'length', None)
    dur_str = f"{dur:.2f}s" if dur is not None else "?s"
    return f"MgAudio('{self.filename}', {dur_str}, sr={getattr(self, 'sr', None)})"

_autoshow

_autoshow(mgf, autoshow)

Display the rendered figure inline when autoshow is True and we are running in a notebook (Jupyter or Colab). Outside a notebook this is a no-op, so scripts and test runs never open viewer windows. Always returns mgf so it can wrap a return statement.

Source code in musicalgestures/_audio.py
67
68
69
70
71
72
73
74
75
def _autoshow(self, mgf: MgFigure, autoshow: bool) -> MgFigure:
    """Display the rendered figure inline when `autoshow` is True and we are
    running in a notebook (Jupyter or Colab). Outside a notebook this is a
    no-op, so scripts and test runs never open viewer windows. Always
    returns `mgf` so it can wrap a return statement."""
    import musicalgestures._utils
    if autoshow and (musicalgestures._utils.in_colab() or musicalgestures._utils.in_ipynb()):
        mgf.show()
    return mgf

ssm

ssm(features='motiongrams', filtertype='Regular', threshold=0.05, blur='None', norm=np.inf, norm_threshold=0.001, cmap='gray_r', use_median=False, kernel_size=5, invert_yaxis=True, combine=False, title=None, target_name=None, overwrite=True)

Compute Self-Similarity Matrix (SSM) by converting the input signal into a suitable feature sequence and comparing each element of the feature sequence with all other elements of the sequence. SSMs can be computed over different input features such as 'motiongrams', 'spectrogram', 'chromagram' and 'tempogram'.

Parameters:

Name Type Description Default
features str

Defines the type of features on which to compute SSM. Possible to compute SSM on 'motiongrams', 'videograms', 'spectrogram', 'chromagram' and 'tempogram'. Defaults to 'motiongrams'.

'motiongrams'
filtertype str

'Regular' turns all values below threshold to 0. 'Binary' turns all values below threshold to 0, above threshold to 1. 'Blob' removes individual pixels with erosion method. Defaults to 'Regular'.

'Regular'
threshold float

Eliminates pixel values less than given threshold. Ranges from 0 to 1. Defaults to 0.05.

0.05
blur str

'Average' to apply a 10px * 10px blurring filter, 'None' otherwise. Defaults to 'None'.

'None'
norm int

Normalize the columns of the feature sequence. Possible to compute Manhattan norm (1), Euclidean norm (2), Minimum norm (-np.inf), Maximum norm (np.inf), etc. Defaults to np.inf.

inf
norm_threshold float

Only the columns with norm at least norm_threshold are normalized. Defaults to 0.001.

0.001
combine bool

For 'motiongrams', compute a single SSM from the concatenated horizontal + vertical motiongram features (both axes of motion in one display) and return a single MgImage instead of an MgList of two. Defaults to False.

False
cmap str

A Colormap instance or registered colormap name. The colormap maps the C values to colors. Defaults to 'gray_r'.

'gray_r'
use_median bool

If True the algorithm applies a median filter on the thresholded frame-difference stream. Defaults to False.

False
kernel_size int

Size of the median filter (if use_median=True) or the erosion filter (if filtertype='blob'). Defaults to 5.

5
invert_yaxis bool

Whether to invert the y axis of the SSM. Defaults to True.

True
title str

Optionally add title to the figure. Possible to set the filename as the title using the string 'filename'. Defaults to None.

None
target_name [type]

Target output name for the SSM. Defaults to None.

None
overwrite bool

Whether to allow overwriting existing files or to automatically increment target filenames to avoid overwriting. Defaults to True.

True

Returns:

Name Type Description
'MgList | MgImage'

if features='motiongrams':

MgList 'MgList | MgImage'

An MgList pointing to the output SSM images (as MgImages).

'MgList | MgImage'

else:

MgImage 'MgList | MgImage'

An MgImage to the output SSM.

Source code in musicalgestures/_ssm.py
 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
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
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
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
365
366
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
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
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
487
488
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
535
536
537
538
539
540
541
542
543
544
def mg_ssm(
        self,
        features: str = 'motiongrams',
        filtertype: str = 'Regular',
        threshold: float = 0.05,
        blur: str = 'None',
        norm: int | float = np.inf,
        norm_threshold: float = 0.001,
        cmap: str = 'gray_r',
        use_median: bool = False,
        kernel_size: int = 5,
        invert_yaxis: bool = True,
        combine: bool = False,
        title: str | None = None,
        target_name: str | None = None,
        overwrite: bool = True) -> "MgList | MgImage":
    """
    Compute Self-Similarity Matrix (SSM) by converting the input signal into a suitable feature sequence and comparing each element of the feature sequence with all other elements of the sequence.
    SSMs can be computed over different input features such as 'motiongrams', 'spectrogram', 'chromagram' and 'tempogram'.

    Args:
        features (str, optional): Defines the type of features on which to compute SSM. Possible to compute SSM on 'motiongrams', 'videograms', 'spectrogram', 'chromagram' and 'tempogram'. Defaults to 'motiongrams'.
        filtertype (str, optional): 'Regular' turns all values below `threshold` to 0. 'Binary' turns all values below `threshold` to 0, above `threshold` to 1. 'Blob' removes individual pixels with erosion method. Defaults to 'Regular'.
        threshold (float, optional): Eliminates pixel values less than given threshold. Ranges from 0 to 1. Defaults to 0.05.
        blur (str, optional): 'Average' to apply a 10px * 10px blurring filter, 'None' otherwise. Defaults to 'None'.
        norm (int, optional): Normalize the columns of the feature sequence. Possible to compute Manhattan norm (1), Euclidean norm (2), Minimum norm (-np.inf), Maximum norm (np.inf), etc. Defaults to np.inf.
        norm_threshold (float, optional): Only the columns with norm at least `norm_threshold` are normalized. Defaults to 0.001.
        combine (bool, optional): For 'motiongrams', compute a single SSM from the concatenated
            horizontal + vertical motiongram features (both axes of motion in one display) and
            return a single MgImage instead of an MgList of two. Defaults to False.
        cmap (str, optional): A Colormap instance or registered colormap name. The colormap maps the C values to colors. Defaults to 'gray_r'.
        use_median (bool, optional): If True the algorithm applies a median filter on the thresholded frame-difference stream. Defaults to False.
        kernel_size (int, optional):  Size of the median filter (if `use_median=True`) or the erosion filter (if `filtertype='blob'`). Defaults to 5.
        invert_yaxis (bool, optional): Whether to invert the y axis of the SSM. Defaults to True.
        title (str, optional): Optionally add title to the figure. Possible to set the filename as the title using the string 'filename'. Defaults to None.
        target_name ([type], optional): Target output name for the SSM. Defaults to None.
        overwrite (bool, optional): Whether to allow overwriting existing files or to automatically increment target filenames to avoid overwriting. Defaults to True.

    Returns:
        # if features='motiongrams':
        MgList: An MgList pointing to the output SSM images (as MgImages).
        # else:
        MgImage: An MgImage to the output SSM.
    """

    # Save figure to png
    if target_name is None:
        target_name = self.of + '_ssm.png'
    else:
        # enforce png
        target_name = os.path.splitext(target_name)[0] + '.png'
    if not overwrite:
        target_name = generate_outfilename(target_name)

    if features == 'motiongrams':
        # Make sure the file is a video file
        if self.__class__.__name__ == 'MgAudio':
            try:
                width, height = get_widthheight(self.filename)
            except:
                print(f'The "{features}" parameter works only on video files. Try "spectrogram", "chromagram" or "tempogram".')
                return

        out_x, out_y = None, None
        target_name_mgx = os.path.splitext(target_name)[0] + '_mgv.png'
        target_name_mgy = os.path.splitext(target_name)[0] + '_mgh.png'

        if not overwrite:
            out_x = generate_outfilename(target_name_mgx)
            out_y = generate_outfilename(target_name_mgy)
        else:
            out_x, out_y = target_name_mgx, target_name_mgy

        mg_motiongrams(
            self,
            filtertype=filtertype,
            threshold=threshold,
            blur=blur,
            use_median=use_median,
            kernel_size=kernel_size,
            inverted_motiongram=False,
            equalize_motiongram=True,
            target_name_mgx=out_x,
            target_name_mgy=out_y,
            overwrite=True)

        # Normalize feature sequence
        X = librosa.util.normalize(self.ssm_fig.data[0].astype('float32'), norm=norm, threshold=norm_threshold)
        Y = librosa.util.normalize(self.ssm_fig.data[1].astype('float32'), norm=norm, threshold=norm_threshold)

        # Combined SSM: stack horizontal + vertical motiongram features per frame so a
        # single self-similarity matrix reflects both axes of motion at once.
        if combine:
            n = min(X.shape[-1], Y.shape[-1])
            XY = np.concatenate([X[..., :n], Y[..., :n]], axis=0)
            XY_ssm = slow_dot(np.transpose(XY), XY, self.length)

            fig, ax = plt.subplots(figsize=(8, 8))
            if title == 'filename':
                ax.set_title('Combined motion SSM: ' + os.path.basename(self.of + self.fex))
            elif title:
                ax.set_title(title)
            else:
                ax.set_title('Combined (horizontal + vertical) motion SSM')
            img = ax.imshow(XY_ssm, aspect='auto', cmap=cmap)
            if invert_yaxis:
                ax.invert_yaxis()
            ax.set_xlabel('Time [frames]')
            ax.set_ylabel('Time [frames]')
            cb_norm = mpl.colors.Normalize(vmin=0, vmax=1.0)
            fig.colorbar(mpl.cm.ScalarMappable(norm=cb_norm, cmap=cmap), ax=ax, aspect=50)
            fig.tight_layout()
            plt.savefig(target_name, format='png', facecolor='white', transparent=False)
            plt.close()
            self.ssm_combined = MgImage(target_name)
            return MgImage(target_name)

        # Compute SSM using dot product
        X_ssm = slow_dot(np.transpose(X), X, self.length)
        Y_ssm = slow_dot(np.transpose(Y), Y, self.length)

       # Plotting Self-Similarity Matrices for motiongrams
        fig = plt.figure(figsize=(8,8))
        gs = gridspec.GridSpec(4, 1)

        ax0 = fig.add_subplot(gs[0])
        ax0.xaxis.set_major_locator(MaxNLocator(8))
        if title is None:
            title = ''
        if title == 'filename':
            title = 'Vertical motiongram: ' + os.path.basename(self.of + self.fex)
        ax0.set_title(title)
        if invert_yaxis:
            ax0.invert_yaxis()
        img0 = ax0.imshow(X, aspect='auto', cmap=cmap)
        fig.colorbar(img0, ax=ax0, aspect=15)
        ax0.set_xlabel('')

        ax1 = fig.add_subplot(gs[1:])
        ax1.xaxis.set_major_locator(MaxNLocator(8))
        ax1.yaxis.set_major_locator(MaxNLocator(8))
        img1 = ax1.imshow(X_ssm, aspect='auto', cmap=cmap)
        if invert_yaxis:
            ax1.invert_yaxis()
        ax1.set_xlabel('Time [frames]')
        ax1.set_ylabel('Time [frames]')
        # Normalize colobar
        norm = mpl.colors.Normalize(vmin=0, vmax=1.0)
        fig.colorbar(mpl.cm.ScalarMappable(norm=norm, cmap=cmap), ax=ax1, aspect=50)
        fig.tight_layout()

        plt.savefig(out_x, format='png', facecolor='white', transparent=False)
        plt.close()

        fig = plt.figure(figsize=(8,8))
        gs = gridspec.GridSpec(4, 1)

        ax0 = fig.add_subplot(gs[0])
        ax0.xaxis.set_major_locator(MaxNLocator(8))
        if title is None:
            title = ''
        if title == 'filename':
            title = 'Horizontal motiongram: ' + os.path.basename(self.of + self.fex)
        ax0.set_title(title)
        ax0.invert_yaxis()
        img0 = ax0.imshow(Y, aspect='auto', cmap=cmap)
        fig.colorbar(img0, ax=ax0, aspect=15)
        ax0.set_xlabel('')

        ax1 = fig.add_subplot(gs[1:])
        ax1.xaxis.set_major_locator(MaxNLocator(8))
        ax1.yaxis.set_major_locator(MaxNLocator(8))
        img1 = ax1.imshow(Y_ssm, aspect='auto', cmap=cmap)
        if invert_yaxis:
            ax1.invert_yaxis()
        ax1.set_xlabel('Time [frames]')
        ax1.set_ylabel('Time [frames]')
        # Normalize colorbar
        norm = mpl.colors.Normalize(vmin=0, vmax=1.0)
        fig.colorbar(mpl.cm.ScalarMappable(norm=norm, cmap=cmap), ax=ax1, aspect=50)
        fig.tight_layout()

        plt.savefig(out_y, format='png', facecolor='white', transparent=False)
        plt.close()

        # mg_ssm also saves the motiongrams SSM as MgImages to self.motiongram_x and self.motiongram_y of the parent MgVideo
        return MgList(MgImage(out_x), MgImage(out_y))

    elif features == 'videograms':
        # Make sure the file is a video file
        if self.__class__.__name__ == 'MgAudio':
            try:
                width, height = get_widthheight(self.filename)
            except:
                print(f'The "{features}" parameter works only on video files. Try "spectrogram", "chromagram" or "tempogram".')
                return

        out_x, out_y = None, None
        target_name_vgx = os.path.splitext(target_name)[0] + '_vgv.png'
        target_name_vgy = os.path.splitext(target_name)[0] + '_vgh.png'

        if not overwrite:
            out_x = generate_outfilename(target_name_vgx)
            out_y = generate_outfilename(target_name_vgy)
        else:
            out_x, out_y = target_name_vgx, target_name_vgy

        videograms = videograms_ffmpeg(self,
                                       target_name_x=out_x,
                                       target_name_y=out_y,
                                       overwrite=True)

        pb = MgProgressbar(total=self.length, prefix='Rendering self-similarity matrices:')

        # Load videograms and normalize them
        vgx = cv2.cvtColor(cv2.imread(videograms[0].filename), cv2.COLOR_RGB2GRAY)
        vgy = cv2.cvtColor(cv2.imread(videograms[1].filename), cv2.COLOR_RGB2GRAY)

        X = librosa.util.normalize(vgx.astype('float32'), norm=norm, threshold=norm_threshold)
        Y = librosa.util.normalize(vgy.astype('float32'), norm=norm, threshold=norm_threshold)
        # Compute SSM using dot product
        X_ssm = slow_dot(np.transpose(X), X, self.length)
        Y_ssm = slow_dot(np.transpose(Y), Y, self.length)

       # Plotting Self-Similarity Matrices for motiongrams
        fig = plt.figure(figsize=(8,8))
        gs = gridspec.GridSpec(4, 1)

        ax0 = fig.add_subplot(gs[0])
        ax0.xaxis.set_major_locator(MaxNLocator(8))
        if title is None:
            title = ''
        if title == 'filename':
            title = 'Vertical videogram: ' + os.path.basename(self.of + self.fex)
        ax0.set_title(title)
        ax0.invert_yaxis()
        img0 = ax0.imshow(X, aspect='auto', cmap=cmap)
        fig.colorbar(img0, ax=ax0, aspect=15)
        ax0.set_xlabel('')

        ax1 = fig.add_subplot(gs[1:])
        ax1.xaxis.set_major_locator(MaxNLocator(8))
        ax1.yaxis.set_major_locator(MaxNLocator(8))
        img1 = ax1.imshow(X_ssm, aspect='auto', cmap=cmap)
        ax1.invert_yaxis()
        ax1.set_xlabel('Time [frames]')
        ax1.set_ylabel('Time [frames]')
        # Normalize colobar
        norm = mpl.colors.Normalize(vmin=0, vmax=1.0)
        fig.colorbar(mpl.cm.ScalarMappable(norm=norm, cmap=cmap), ax=ax1, aspect=50)
        fig.tight_layout()

        plt.savefig(out_x, format='png', facecolor='white', transparent=False)
        plt.close()

        fig = plt.figure(figsize=(8,8))
        gs = gridspec.GridSpec(4, 1)

        ax0 = fig.add_subplot(gs[0])
        ax0.xaxis.set_major_locator(MaxNLocator(8))
        if title is None:
            title = ''
        if title == 'filename':
            title = 'Horizontal videogram: ' + os.path.basename(self.of + self.fex)
        ax0.set_title(title)
        ax0.invert_yaxis()
        img0 = ax0.imshow(Y, aspect='auto', cmap=cmap)
        fig.colorbar(img0, ax=ax0, aspect=15)
        ax0.set_xlabel('')

        ax1 = fig.add_subplot(gs[1:])
        ax1.xaxis.set_major_locator(MaxNLocator(8))
        ax1.yaxis.set_major_locator(MaxNLocator(8))
        img1 = ax1.imshow(Y_ssm, aspect='auto', cmap=cmap)
        ax1.invert_yaxis()
        ax1.set_xlabel('Time [frames]')
        ax1.set_ylabel('Time [frames]')
        # Normalize colorbar
        norm = mpl.colors.Normalize(vmin=0, vmax=1.0)
        fig.colorbar(mpl.cm.ScalarMappable(norm=norm, cmap=cmap), ax=ax1, aspect=50)
        fig.tight_layout()

        self.ssm_fig = MgFigure(figure=None, figure_type='video.ssm', data=(vgx, vgy), layers=None, image=(target_name_vgx, target_name_vgy))

        plt.savefig(out_y, format='png', facecolor='white', transparent=False)
        plt.close()

        # mg_ssm also saves the motiongrams SSM as MgImages to self.motiongram_x and self.motiongram_y of the parent MgVideo
        return MgList(MgImage(out_x), MgImage(out_y))

    elif features == 'spectrogram':
        if not has_audio(self.filename):
            print('The video has no audio track.')
            return

        # ffprobe for the rate and an extracted track for the samples: librosa 1.0
        # dropped the audioread fallback and can no longer read a video container.
        sr = get_samplerate(self.filename)
        x, sr = librosa.load(audio_source(self.filename), sr=sr)
        frame_length = 512
        hop_length = 128
        spectrogram = np.abs(librosa.stft(x, n_fft=frame_length, hop_length=hop_length))

        X, sr_X, formatter = smooth_downsample_feature_sequence(spectrogram, sr/hop_length)
        # Normalize columns of the feature sequence
        X = librosa.util.normalize(X.astype('float32'), norm=norm, threshold=norm_threshold)
        # Compute SSM using dot product
        X_ssm = slow_dot(np.transpose(X), X, self.length)
       # Plotting SSM for spectrogram
        fig = plt.figure(figsize=(8,8))
        gs = gridspec.GridSpec(4, 1)

        ax0 = fig.add_subplot(gs[0])
        if title is None:
            title = ''
        if title == 'filename':
            title = 'Spectrogram: ' + os.path.basename(self.of + self.fex)
        ax0.set_title(title)
        img0 = librosa.display.specshow(librosa.amplitude_to_db(X, ref=np.max), y_axis='linear', x_axis='time', cmap=cmap, sr=sr, hop_length=hop_length)
        fig.colorbar(img0, ax=ax0, format="%+2.f dB")
        # Format ticks
        ax0.xaxis.set_major_formatter(formatter)
        ax0.xaxis.set_major_locator(MaxNLocator(8))
        ax0.set_xlabel('')

        ax1 = fig.add_subplot(gs[1:])
        ax1.xaxis.set_major_locator(MaxNLocator(8))
        ax1.yaxis.set_major_locator(MaxNLocator(8))
        left = - (frame_length / sr) / 2
        right = spectrogram.shape[1] * hop_length / sr + (frame_length / sr) / 2

        img1 = ax1.imshow(librosa.amplitude_to_db(X_ssm, ref=np.max), aspect='auto', cmap=cmap, extent=[left,right,right,left])
        if invert_yaxis:
            ax1.invert_yaxis()
        ax1.set_xlabel('Time [seconds]')
        ax1.set_ylabel('Time [seconds]')
        fig.colorbar(img1, ax=ax1, aspect=50, format="%+2.f dB")
        fig.tight_layout()

        self.ssm_fig = MgFigure(figure=fig, figure_type='audio.ssm', data=X_ssm, layers=None, image=target_name)

        plt.savefig(target_name, format='png', facecolor='white', transparent=False)
        plt.close()

        return MgImage(target_name)

    elif features == 'chromagram':
        if not has_audio(self.filename):
            print('The video has no audio track.')
            return

        # ffprobe for the rate and an extracted track for the samples: librosa 1.0
        # dropped the audioread fallback and can no longer read a video container.
        sr = get_samplerate(self.filename)
        x, sr = librosa.load(audio_source(self.filename), sr=sr)
        frame_length = 512
        hop_length = 128
        spectrogram = np.abs(librosa.stft(x, n_fft=frame_length, hop_length=hop_length))
        chromagram = librosa.feature.chroma_stft(S=spectrogram, sr=sr, hop_length=hop_length, n_fft=frame_length)

        X, sr_X, formatter = smooth_downsample_feature_sequence(chromagram, sr/hop_length)
        # Normalize feature sequence
        X = librosa.util.normalize(X.astype('float32'), norm=norm, threshold=norm_threshold)
        # Compute SSM using dot product
        X_ssm = slow_dot(np.transpose(X), X, self.length)

       # Plotting SSM for chromagram
        fig = plt.figure(figsize=(8,8))
        gs = gridspec.GridSpec(4, 1)

        ax0 = fig.add_subplot(gs[0])
        if title is None:
            title = ''
        if title == 'filename':
            title = 'Chromagram: ' + os.path.basename(self.of + self.fex)
        ax0.set_title(title)
        img0 = librosa.display.specshow(X, y_axis='chroma', x_axis='time', cmap=cmap, sr=sr, hop_length=hop_length)

        # Format ticks
        ax0.xaxis.set_major_formatter(formatter)
        ax0.xaxis.set_major_locator(MaxNLocator(8))
        # Normalize colorbar
        norm = mpl.colors.Normalize(vmin=0, vmax=1.0)
        fig.colorbar(mpl.cm.ScalarMappable(norm=norm, cmap=cmap), ax=ax0, aspect=15)
        ax0.set_xlabel('')

        ax1 = fig.add_subplot(gs[1:])
        ax1.xaxis.set_major_locator(MaxNLocator(8))
        ax1.yaxis.set_major_locator(MaxNLocator(8))
        left = - (frame_length / sr) / 2
        right = chromagram.shape[1] * hop_length / sr + (frame_length / sr) / 2

        img1 = ax1.imshow(X_ssm, aspect='auto', cmap=cmap, extent=[left,right,right,left])
        if invert_yaxis:
            ax1.invert_yaxis()
        ax1.set_xlabel('Time [seconds]')
        ax1.set_ylabel('Time [seconds]')
        # Normalize colorbar
        norm = mpl.colors.Normalize(vmin=0, vmax=1.0)
        fig.colorbar(mpl.cm.ScalarMappable(norm=norm, cmap=cmap), ax=ax1, aspect=50)
        fig.tight_layout()

        self.ssm_fig = MgFigure(figure=fig, figure_type='audio.ssm', data=X_ssm, layers=None, image=target_name)

        plt.savefig(target_name, format='png', facecolor='white', transparent=False)
        plt.close()

        return MgImage(target_name)

    elif features == 'tempogram':
        if not has_audio(self.filename):
            print('The video has no audio track.')
            return

        # ffprobe for the rate and an extracted track for the samples: librosa 1.0
        # dropped the audioread fallback and can no longer read a video container.
        sr = get_samplerate(self.filename)
        x, sr = librosa.load(audio_source(self.filename), sr=sr)
        frame_length = 1024
        hop_length = 512

        oenv = librosa.onset.onset_strength(y=x, sr=sr, hop_length=hop_length)
        tempogram = librosa.feature.tempogram(onset_envelope=oenv, sr=sr, hop_length=hop_length, win_length=frame_length)
        # Estimate the global tempo for display purposes
        tempo = librosa.feature.tempo(onset_envelope=oenv, sr=sr, hop_length=hop_length)[0]

        X, sr_X, formatter = smooth_downsample_feature_sequence(tempogram, sr/hop_length)
        # Normalize feature sequence
        X = librosa.util.normalize(X.astype('float32'), norm=norm, threshold=norm_threshold)
        # Compute SSM using dot product
        X_ssm = slow_dot(np.transpose(X), X, self.length)

       # Plotting SSM for tempogram
        fig = plt.figure(figsize=(8,8))
        gs = gridspec.GridSpec(4, 1)

        ax0 = fig.add_subplot(gs[0])
        if title is None:
            title = ''
        if title == 'filename':
            title = 'Tempogram: ' + os.path.basename(self.of + self.fex)
        ax0.set_title(title)
        img0 = librosa.display.specshow(X, y_axis='tempo', x_axis='time', cmap=cmap, sr=sr, hop_length=hop_length)

        # Normalize colorbar
        norm = mpl.colors.Normalize(vmin=0, vmax=1.0)
        fig.colorbar(mpl.cm.ScalarMappable(norm=norm, cmap=cmap), ax=ax0, aspect=15)
        ax0.axhline(tempo, color='w', linestyle='--', alpha=1, label='Estimated tempo={:g}'.format(tempo))
        ax0.xaxis.set_major_locator(MaxNLocator(8))
        ax0.xaxis.set_major_formatter(formatter)
        ax0.legend(loc='upper right')
        ax0.set_xlabel('')

        ax1 = fig.add_subplot(gs[1:])
        ax1.xaxis.set_major_locator(MaxNLocator(8))
        ax1.yaxis.set_major_locator(MaxNLocator(8))
        left = - (frame_length / sr) / 2
        right = tempogram.shape[1] * hop_length / sr + (frame_length / sr) / 2

        img1 = ax1.imshow(X_ssm, aspect='auto', cmap=cmap, extent=[left,right,right,left])
        if invert_yaxis:
            ax1.invert_yaxis()
        ax1.set_xlabel('Time [seconds]')
        ax1.set_ylabel('Time [seconds]')
        norm = mpl.colors.Normalize(vmin=0, vmax=1.0)
        fig.colorbar(mpl.cm.ScalarMappable(norm=norm, cmap=cmap), ax=ax1, aspect=50)
        fig.tight_layout()

        self.ssm_fig = MgFigure(figure=fig, figure_type='audio.ssm', data=X_ssm, layers=None, image=target_name)

        plt.savefig(target_name, format='png', facecolor='white', transparent=False)
        plt.close()

        return MgImage(target_name)

    else:
        print(f'Unrecognized feature: "{features}". Try "motiongrams", "videograms, "spectrogram", "chromagram" or "tempogram".')

_load

_load()

Load (and cache) the audio samples with librosa.

The decoded array is cached on the object so repeated audio analyses (waveform, spectrogram, descriptors, …) don't re-decode the file each time. The cache is invalidated automatically if self.sr changes.

Returns:

Name Type Description
tuple

(y, sr) — the audio samples and their sample rate.

Source code in musicalgestures/_audio.py
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
def _load(self):
    """
    Load (and cache) the audio samples with librosa.

    The decoded array is cached on the object so repeated audio analyses
    (waveform, spectrogram, descriptors, …) don't re-decode the file each time.
    The cache is invalidated automatically if ``self.sr`` changes.

    Returns:
        tuple: (y, sr) — the audio samples and their sample rate.
    """
    # getattr guard: MgVideo inherits the audio methods but does not set _y_cache
    # in its own __init__, so the attribute may be absent on the instance.
    cache = getattr(self, '_y_cache', None)
    if cache is None or cache[1] != self.sr:
        y, sr = librosa.load(audio_source(self.filename), sr=self.sr)
        cache = (y, sr)
        self._y_cache = cache
    return cache

numpy

numpy()

Read the original file of the MgAudio object as a numpy array using librosa.

Source code in musicalgestures/_audio.py
 99
100
101
102
def numpy(self):
    "Read the original file of the MgAudio object as a numpy array using librosa."
    self.y, self.sr = self._load()
    return self.y

format_time

format_time(ax, original_time=True, original_duration=None)

Format time for audio plotting of video file. This is useful if one wants to plot the original time of the video when frames have been skipped beforehand.

Parameters:

Name Type Description Default
ax str

Axis of the figure.

required
original_time bool

Whether to get the original time for audio plotting or not. Defaults to True.

True
original_duration bool

Whether to add the original duration of the file to be formatted manually. Defaults to None.

None
Source code in musicalgestures/_audio.py
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
def format_time(self, ax, original_time: bool = True, original_duration=None):
        """
        Format time for audio plotting of video file. This is useful if one wants to plot the original time of the video when frames have been skipped beforehand.

        Args:
            ax (str, optional): Axis of the figure.
            original_time (bool, optional): Whether to get the original time for audio plotting or not. Defaults to True.
            original_duration (bool, optional): Whether to add the original duration of the file to be formatted manually. Defaults to None.
        """
        # Get original duration from video file
        try:
            if original_duration is not None:
                original_duration = original_duration
            else:
                if original_time:
                    original_duration = float(info(self.filename)[2]['TAG:title'])
                else:
                    original_duration = float(info(self.filename)[2]['duration'])
        except:
            return 

        time = np.round(np.linspace(0, original_duration, 10), 1)

        for i, v in enumerate(time):
            if original_duration > 3600:
                minutes, sec = divmod(v, 60)
                hour, minutes = divmod(minutes, 60)
                time[i] = '%d.%02d.%02d' % (hour, minutes, sec)
            if original_duration > 60:
                minutes, sec = divmod(v, 60)
                time[i] = '%02d.%02d' % (minutes, sec)

        ax.xaxis.set_major_locator(ticker.LinearLocator(numticks=10))
        if original_duration > 60:
            ax.xaxis.set_major_formatter(ticker.FixedFormatter(list(map(lambda x: str(x).replace('.', ':'), list(time)))))
        else:  
            ax.xaxis.set_major_formatter(ticker.FixedFormatter(list(time)))

waveform

waveform(dpi=300, autoshow=True, raw=False, colored=False, image_width=2500, image_height=500, fmin=500, fmax=None, cmap='freesound', original_time=True, title=None, target_name=None, overwrite=True)

Renders a figure showing the waveform of the video/audio file.

Parameters:

Name Type Description Default
dpi int

Image quality of the rendered figure in DPI. Defaults to 300.

300
autoshow bool

Whether to show the resulting figure automatically (inline, when running in a notebook; a no-op otherwise). Defaults to True.

True
raw bool

Whether to show labels and ticks on the plot. Defaults to False.

False
colored bool

Whether to create a colored waveform image (freesound-style) from an audio input file. Defauts to False.

False
image_width int

Number of pixels for the colored waveform image width. Defaults to 2500.

2500
image_height int

Number of pixels for the colored waveform image height. Defaults to 500.

500
fmin int

Minimum frequency for computing spectral centroid for the colored waveform image. Defaults to 500.

500
fmax int

Maximum frequency for computing spectral centroid for the colored waveform image. Defaults to None (i.e. Nyquist frequency).

None
cmap str

Colormap used for coloring the waveform, all colormaps included with matplotlib can be used. Defaults to 'freesound'.

'freesound'
original_time bool

Whether to plot original time or not. This parameter can be useful if the video file has been shortened beforehand (e.g. skip). Defaults to True.

True
title str

Optionally add title to the figure. Possible to set the filename as the title using the string 'filename'. Defaults to None.

None
target_name str

The name of the output image. Defaults to None (which assumes that the input filename with the suffix "_waveform.png" should be used).

None
overwrite bool

Whether to allow overwriting existing files or to automatically increment target filenames to avoid overwriting. Defaults to True.

True

Returns:

Name Type Description
MgFigure MgFigure

An MgFigure object referring to the internal figure and its data.

Source code in musicalgestures/_audio.py
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
239
240
241
242
243
244
245
246
247
def waveform(self, dpi: int = 300, autoshow: bool = True, raw: bool = False, colored: bool = False, image_width: int = 2500, image_height: int = 500, fmin: int = 500, fmax: int | None = None, cmap: str = 'freesound', original_time: bool = True, title: str | None = None, target_name: str | None = None, overwrite: bool = True) -> MgFigure:
    """
    Renders a figure showing the waveform of the video/audio file.

    Args:
        dpi (int, optional): Image quality of the rendered figure in DPI. Defaults to 300.
        autoshow (bool, optional): Whether to show the resulting figure automatically (inline, when running in a notebook; a no-op otherwise). Defaults to True.
        raw (bool, optional): Whether to show labels and ticks on the plot. Defaults to False.
        colored (bool, optional): Whether to create a colored waveform image (freesound-style) from an audio input file. Defauts to False.
        image_width (int, optional): Number of pixels for the colored waveform image width. Defaults to 2500.
        image_height (int, optional): Number of pixels for the colored waveform image height. Defaults to 500.
        fmin (int, optional): Minimum frequency for computing spectral centroid for the colored waveform image. Defaults to 500.
        fmax (int, optional): Maximum frequency for computing spectral centroid for the colored waveform image. Defaults to None (i.e. Nyquist frequency).
        cmap (str, optional): Colormap used for coloring the waveform, all colormaps included with matplotlib can be used. Defaults to 'freesound'.
        original_time (bool, optional): Whether to plot original time or not. This parameter can be useful if the video file has been shortened beforehand (e.g. skip). Defaults to True.
        title (str, optional): Optionally add title to the figure. Possible to set the filename as the title using the string 'filename'. Defaults to None.
        target_name (str, optional): The name of the output image. Defaults to None (which assumes that the input filename with the suffix "_waveform.png" should be used).
        overwrite (bool, optional): Whether to allow overwriting existing files or to automatically increment target filenames to avoid overwriting. Defaults to True.

    Returns:
        MgFigure: An MgFigure object referring to the internal figure and its data.
    """

    if not has_audio(self.filename):
        print('The video has no audio track.')
        return

    target_name = resolve_filename(self.of, '_waveform.png', target_name, overwrite)

    if colored:
        # Process audio chunks and compute spectral centroid for creating the colored waveform
        processor = MgAudioProcessor(self.filename, self.n_fft, fmin, fmax)
        y = MgWaveformImage(image_width, image_height, cmap)
        sr = processor.audio_file.samplerate

        samples_per_pixel = processor.audio_file.frames / float(image_width)

        for x in range(image_width):
            seek_point = int(x * samples_per_pixel)
            next_seek_point = int((x + 1) * samples_per_pixel)
            spectral_centroid = processor.spectral_centroid(seek_point) 
            peaks = processor.peaks(seek_point, next_seek_point)        
            y.draw_peaks(x, peaks, spectral_centroid) 
    else:
        y, sr = self._load()

    fig, ax = plt.subplots(figsize=(12, 4), dpi=dpi)
    fig.patch.set_facecolor('white') # make sure background is white
    fig.patch.set_alpha(1)

    # add title
    if title is None:
        title = ''
    if title == 'filename':
        title = os.path.basename(self.filename)
    fig.suptitle(title, fontsize=16)

    if colored:
        # Get the original duration of the audio file and format it to HH:MM:SS
        original_duration = float(processor.audio_file.frames / processor.audio_file.samplerate)
        self.format_time(ax, original_duration=original_duration)
        ax.imshow(y.image.astype('uint8'), interpolation='nearest')
        # Replace yticks with values between -1 and 1 for practicalities
        ax.yaxis.set_major_locator(ticker.LinearLocator(numticks=len(ax.get_yticks())))

        if abs(processor.max_level) < 0.1 or abs(processor.min_level) < 0.1:
            ax.yaxis.set_major_formatter(ticker.FixedFormatter(list(np.round(np.linspace(processor.max_level, processor.min_level, len(ax.get_yticks())),2))))
        else:
            print(abs(processor.max_level), abs(processor.min_level))
            ax.yaxis.set_major_formatter(ticker.FixedFormatter(list(np.round(np.linspace(processor.max_level, processor.min_level, len(ax.get_yticks())),1))))

    else:
        # Adapt audio file plotting when skipping frames of a video file
        self.format_time(ax, original_time=original_time)
        librosa.display.waveshow(y, sr=sr, ax=ax)

    if raw:
        fig.patch.set_visible(False)
        fig.suptitle('')
        ax.axis('off')

    fig.tight_layout()
    plt.savefig(target_name, format='png', transparent=False)

    # Always close the pyplot figure: the returned MgFigure displays the saved
    # PNG via its rich repr, so leaving the figure open would cause the inline
    # backend to render a second (duplicate) copy in notebooks.
    plt.close(fig)

    # create MgFigure
    data = {
        "sr": sr,
        "of": self.of,
        "y": y,
        "length": self.length
    }

    mgf = MgFigure(
        figure=fig,
        figure_type='audio.waveform',
        data=data,
        layers=None,
        image=target_name)

    return self._autoshow(mgf, autoshow)

spectrogram

spectrogram(fmin=0.0, fmax=None, n_mels=128, power=2.0, top_db=80.0, dpi=300, autoshow=True, raw=False, original_time=False, title=None, target_name=None, overwrite=True)

Renders a figure showing the mel-scaled spectrogram of the video/audio file.

Parameters:

Name Type Description Default
n_mels int

The number of filters to use for filtering the frequency domain. Affects the vertical resolution (sharpness) of the spectrogram. NB: Too high values with relatively small window sizes can result in artifacts (typically black lines) in the resulting image. Defaults to 128.

128
fmin float

Lowest frequency (in Hz). Defaults to 0.0.

0.0
fmax float

Highest frequency (in Hz). Defaults to None, use fmax = sr / 2.0.

None
power float

The steepness of the curve for the color mapping. Defaults to 2.

2.0
top_db float

threshold the output at top_db below the peak: max(20 * log10(S/ref)) - top_db. Defaults to 80.0.

80.0
dpi int

Image quality of the rendered figure in DPI. Defaults to 300.

300
autoshow bool

Whether to show the resulting figure automatically (inline, when running in a notebook; a no-op otherwise). Defaults to True.

True
raw bool

Whether to show labels and ticks on the plot. Defaults to False.

False
original_time bool

Whether to plot original time or not. This parameter can be useful if the video file has been shortened beforehand (e.g. skip). Defaults to False.

False
title str

Optionally add title to the figure. Possible to set the filename as the title using the string 'filename'. Defaults to None.

None
target_name str

The name of the output image. Defaults to None (which assumes that the input filename with the suffix "_spectrogram.png" should be used).

None
overwrite bool

Whether to allow overwriting existing files or to automatically increment target filenames to avoid overwriting. Defaults to True.

True

Returns:

Name Type Description
MgFigure MgFigure

An MgFigure object referring to the internal figure and its data.

Source code in musicalgestures/_audio.py
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
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
def spectrogram(self, fmin: float = 0.0, fmax: float | None = None, n_mels: int = 128, power: float = 2.0, top_db: float = 80.0, dpi: int = 300, autoshow: bool = True, raw: bool = False, original_time: bool = False, title: str | None = None, target_name: str | None = None, overwrite: bool = True) -> MgFigure:
    """
    Renders a figure showing the mel-scaled spectrogram of the video/audio file.

    Args:
        n_mels (int, optional): The number of filters to use for filtering the frequency domain. Affects the vertical resolution (sharpness) of the spectrogram. NB: Too high values with relatively small window sizes can result in artifacts (typically black lines) in the resulting image. Defaults to 128.
        fmin (float, optional): Lowest frequency (in Hz). Defaults to 0.0.
        fmax (float, optional): Highest frequency (in Hz). Defaults to None, use fmax = sr / 2.0.
        power (float, optional): The steepness of the curve for the color mapping. Defaults to 2.
        top_db (float, optional): threshold the output at top_db below the peak: max(20 * log10(S/ref)) - top_db. Defaults to 80.0.
        dpi (int, optional): Image quality of the rendered figure in DPI. Defaults to 300.
        autoshow (bool, optional): Whether to show the resulting figure automatically (inline, when running in a notebook; a no-op otherwise). Defaults to True.
        raw (bool, optional): Whether to show labels and ticks on the plot. Defaults to False.
        original_time (bool, optional): Whether to plot original time or not. This parameter can be useful if the video file has been shortened beforehand (e.g. skip). Defaults to False.
        title (str, optional): Optionally add title to the figure. Possible to set the filename as the title using the string 'filename'. Defaults to None.
        target_name (str, optional): The name of the output image. Defaults to None (which assumes that the input filename with the suffix "_spectrogram.png" should be used).
        overwrite (bool, optional): Whether to allow overwriting existing files or to automatically increment target filenames to avoid overwriting. Defaults to True.

    Returns:
        MgFigure: An MgFigure object referring to the internal figure and its data.
    """

    if not has_audio(self.filename):
        print('The video has no audio track.')
        return

    target_name = resolve_filename(self.of, '_spectrogram.png', target_name, overwrite)

    y, sr = self._load()

    S = librosa.feature.melspectrogram(
        y=y, sr=sr, n_mels=n_mels, n_fft=self.n_fft, hop_length=self.hop_length, power=power, fmin=fmin, fmax=fmax)

    fig, ax = plt.subplots(figsize=(12, 4), dpi=dpi)
    # Add title
    if title is None:
        title = ''
    if title == 'filename':
        title = os.path.basename(self.filename)
    fig.suptitle(title, fontsize=16)

    fig.patch.set_facecolor('white') # make sure background is white
    fig.patch.set_alpha(1)

    # Display spectrogram
    img = librosa.display.specshow(librosa.power_to_db(S, ref=np.max, top_db=top_db), 
                                   sr=sr, y_axis='mel', fmin=fmin, fmax=fmax, x_axis='time', hop_length=self.hop_length, ax=ax)

    colorbar_ticks = range(-120, 1, 10)
    cb = fig.colorbar(img, format='%+2.0f dB', ticks=colorbar_ticks)

    # get rid of "default" ticks
    ax.yaxis.set_minor_locator(matplotlib.ticker.NullLocator())

    # Pin the time axis to the actual spectrogram extent so the container
    # duration (which can be longer than the decoded audio) does not leave
    # trailing whitespace or mislabel the timeline.
    xmax = S.shape[1] * self.hop_length / sr
    ax.set_xlim(0, xmax)

    freq_ticks = [elem*100 for elem in range(10)]
    freq_ticks = []
    freq = 100
    while freq < sr/2:
        freq_ticks.append(freq)
        freq *= 1.3

    freq_ticks = [round(elem, -2) for elem in freq_ticks]
    freq_ticks.append(sr/2)
    freq_ticks_labels = [str(round(elem/1000, 1)) + 'k' if elem > 1000 else int(round(elem)) for elem in freq_ticks]

    ax.set(yticks=(freq_ticks))
    ax.set(yticklabels=(freq_ticks_labels))

    # Adapt the plotting of the audio file's time when skipping frames of a video file
    self.format_time(ax, original_time, original_duration=None if original_time else xmax)

    if raw:
        fig.patch.set_visible(False)
        fig.suptitle('')
        ax.axis('off')
        cb.remove()

    plt.tight_layout()
    plt.savefig(target_name, format='png', transparent=False)

    # Always close the pyplot figure: the returned MgFigure displays the saved
    # PNG via its rich repr, so leaving the figure open would cause the inline
    # backend to render a second (duplicate) copy in notebooks.
    plt.close(fig)

    # create MgFigure
    data = {
        "hop_size": self.hop_length,
        "sr": sr,
        "of": self.of,
        "S": S,
        "length": self.length
    }

    mgf = MgFigure(
        figure=fig,
        figure_type='audio.spectrogram',
        data=data,
        layers=None,
        image=target_name)

    return self._autoshow(mgf, autoshow)

tempogram

tempogram(dpi=300, autoshow=True, raw=False, onset_strength=True, original_time=False, title=None, target_name=None, overwrite=True)

Renders a figure with a plots of onset strength and tempogram of the video/audio file.

Parameters:

Name Type Description Default
dpi int

Image quality of the rendered figure in DPI. Defaults to 300.

300
autoshow bool

Whether to show the resulting figure automatically (inline, when running in a notebook; a no-op otherwise). Defaults to True.

True
raw bool

Whether to show labels and ticks on the plot. Defaults to False.

False
onset_strength bool

Whether to include the onset-strength panel above the tempogram. Set to False for just the tempogram in a single-panel figure (the same size as spectrogram/chromagram). Defaults to True.

True
original_time bool

Whether to plot original time or not. This parameter can be useful if the video file has been shortened beforehand (e.g. skip). Defaults to False.

False
title str

Optionally add title to the figure. Possible to set the filename as the title using the string 'filename'. Defaults to None.

None
target_name str

The name of the output image. Defaults to None (which assumes that the input filename with the suffix "_tempogram.png" should be used).

None
overwrite bool

Whether to allow overwriting existing files or to automatically increment target filenames to avoid overwriting. Defaults to True.

True

Returns:

Name Type Description
MgFigure MgFigure

An MgFigure object referring to the internal figure and its data.

Source code in musicalgestures/_audio.py
359
360
361
362
363
364
365
366
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
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
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
def tempogram(self, dpi: int = 300, autoshow: bool = True, raw: bool = False, onset_strength: bool = True, original_time: bool = False, title: str | None = None, target_name: str | None = None, overwrite: bool = True) -> MgFigure:
    """
    Renders a figure with a plots of onset strength and tempogram of the video/audio file.

    Args:
        dpi (int, optional): Image quality of the rendered figure in DPI. Defaults to 300.
        autoshow (bool, optional): Whether to show the resulting figure automatically (inline, when running in a notebook; a no-op otherwise). Defaults to True.
        raw (bool, optional): Whether to show labels and ticks on the plot. Defaults to False.
        onset_strength (bool, optional): Whether to include the onset-strength panel above the
            tempogram. Set to False for just the tempogram in a single-panel figure (the same
            size as spectrogram/chromagram). Defaults to True.
        original_time (bool, optional): Whether to plot original time or not. This parameter can be useful if the video file has been shortened beforehand (e.g. skip). Defaults to False.
        title (str, optional): Optionally add title to the figure. Possible to set the filename as the title using the string 'filename'. Defaults to None.
        target_name (str, optional): The name of the output image. Defaults to None (which assumes that the input filename with the suffix "_tempogram.png" should be used).
        overwrite (bool, optional): Whether to allow overwriting existing files or to automatically increment target filenames to avoid overwriting. Defaults to True.

    Returns:
        MgFigure: An MgFigure object referring to the internal figure and its data.
    """

    if not has_audio(self.filename):
        print('The video has no audio track.')
        return

    target_name = resolve_filename(self.of, '_tempogram.png', target_name, overwrite)

    y, sr = self._load()

    oenv = librosa.onset.onset_strength(y=y, sr=sr, hop_length=self.hop_length)

    tempogram = librosa.feature.tempogram(
        onset_envelope=oenv, sr=sr, hop_length=self.hop_length)

    # Estimate the global tempo for display purposes
    tempo = librosa.feature.tempo(
        onset_envelope=oenv, sr=sr, hop_length=self.hop_length)[0]

    if onset_strength:
        fig, axes = plt.subplots(nrows=2, figsize=(12, 4), dpi=dpi, sharex=True)
        onset_ax, tempo_ax = axes[0], axes[1]
    else:
        # Single-panel tempogram, matching the spectrogram/chromagram figure size
        fig, tempo_ax = plt.subplots(figsize=(12, 4), dpi=dpi)
        onset_ax = None
    fig.patch.set_facecolor('white') # make sure background is white
    fig.patch.set_alpha(1)

    # add title
    if title is None:
        title = ''
    if title == 'filename':
        title = os.path.basename(self.filename)
    fig.suptitle(title, fontsize=16)

    times = librosa.times_like(oenv, sr=sr, hop_length=self.hop_length)

    if onset_ax is not None:
        onset_ax.plot(times, oenv, label='Onset strength')
        onset_ax.label_outer()
        onset_ax.legend(frameon=True)

    img = librosa.display.specshow(tempogram, sr=sr, hop_length=self.hop_length,
                                   x_axis='time', y_axis='tempo', cmap='magma', ax=tempo_ax)
    fig.colorbar(img, ax=tempo_ax)
    tempo_bpm = float(np.atleast_1d(tempo)[0])
    tempo_ax.set(title='Tempogram (estimated tempo = {:.1f} BPM)'.format(tempo_bpm))

    # Adapt the plotting of the audio file's time when skipping frames of a video file
    self.format_time(tempo_ax, original_time)

    if raw:
        fig.patch.set_visible(False)
        fig.suptitle('')
        tempo_ax.axis('off')
        if onset_ax is not None:
            onset_ax.axis('off')

    plt.tight_layout()
    plt.savefig(target_name, format='png', transparent=False)

    # Always close the pyplot figure: the returned MgFigure displays the saved
    # PNG via its rich repr, so leaving the figure open would cause the inline
    # backend to render a second (duplicate) copy in notebooks.
    plt.close(fig)

    # create MgFigure
    data = {
        "hop_size": self.hop_length,
        "sr": sr,
        "of": self.of,
        "times": times,
        "onset_env": oenv,
        "tempogram": tempogram,
        "tempo": tempo
    }

    mgf = MgFigure(
        figure=fig,
        figure_type='audio.tempogram',
        data=data,
        layers=None,
        image=target_name)

    return self._autoshow(mgf, autoshow)

hpss

hpss(dim=2, n_mels=128, fmin=0.0, fmax=None, kernel_size=31, margin=(1.0, 5.0), power=2.0, top_db=80.0, mask=False, residual=False, dpi=300, autoshow=True, original_time=False, title=None, target_name=None, overwrite=True)

Renders a figure with a plots of harmonic and percussive components of the audio file.

Parameters:

Name Type Description Default
dim str

Whether to plot hpss in one (i.e. waveform) or two (i.e. spectrogram) dimensions. Defaults to 2.

2
n_mels int

Number of Mel bands to generate. Defaults to 128.

128
fmin float

Lowest frequency (in Hz). Defaults to 0.0.

0.0
fmax float

Highest frequency (in Hz). Defaults to None, use fmax = sr / 2.0.

None
kernel_size int or tuple

Kernel size(s) for the median filters. If tuple, the first value specifies the width of the harmonic filter, and the second value specifies the width of the percussive filter. Defaults to 31.

31
margin float or tuple

Margin size(s) for the masks (as described in this paper). If tuple, the first value specifies the margin of the harmonic mask, and the second value specifies the margin of the percussive mask. Defaults to (1.0,5.0).

(1.0, 5.0)
power float

Exponent for the Wiener filter when constructing soft mask matrices. Defaults to 2.0.

2.0
top_db float

threshold the output at top_db below the peak: max(20 * log10(S/ref)) - top_db. Defaults to 80.0.

80.0
mask bool

Return the masking matrices instead of components. Defaults to False.

False
residual bool

Whether to return residual components of the audio file or not. Defaults to False.

False
dpi int

Image quality of the rendered figure in DPI. Defaults to 300.

300
autoshow bool

Whether to show the resulting figure automatically (inline, when running in a notebook; a no-op otherwise). Defaults to True.

True
original_time bool

Whether to plot original time or not. This parameter can be useful if the video file has been shortened beforehand (e.g. skip). Defaults to False.

False
title str

Optionally add title to the figure. Possible to set the filename as the title using the string 'filename'. Defaults to None.

None
target_name str

The name of the output image. Defaults to None (which assumes that the input filename with the suffix "_hpss.png" should be used).

None
overwrite bool

Whether to allow overwriting existing files or to automatically increment target filenames to avoid overwriting. Defaults to True.

True

Returns:

Name Type Description
MgFigure MgFigure

An MgFigure object referring to the internal figure and its data.

Source code in musicalgestures/_audio.py
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
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
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
def hpss(self, dim: int = 2, n_mels: int = 128, fmin: float = 0.0, fmax: float | None = None, kernel_size: int | tuple = 31, margin: float | tuple = (1.0,5.0), power: float = 2.0, top_db: float = 80.0, mask: bool = False, residual: bool = False, dpi: int = 300, autoshow: bool = True, original_time: bool = False, title: str | None = None, target_name: str | None = None, overwrite: bool = True) -> MgFigure:
    """
    Renders a figure with a plots of harmonic and percussive components of the audio file.

    Args:
        dim (str, optional): Whether to plot hpss in one (i.e. waveform) or two (i.e. spectrogram) dimensions. Defaults to 2.
        n_mels (int, optional): Number of Mel bands to generate. Defaults to 128.
        fmin (float, optional): Lowest frequency (in Hz). Defaults to 0.0.
        fmax (float, optional): Highest frequency (in Hz). Defaults to None, use fmax = sr / 2.0.
        kernel_size (int or tuple, optional): Kernel size(s) for the median filters. If tuple, the first value specifies the width of the harmonic filter, and the second value specifies the width of the percussive filter. Defaults to 31.
        margin (float or tuple, optional): Margin size(s) for the masks (as described in this [paper](https://archives.ismir.net/ismir2014/paper/000127.pdf)). If tuple, the first value specifies the margin of the harmonic mask, and the second value specifies the margin of the percussive mask. Defaults to (1.0,5.0).
        power (float, optional): Exponent for the Wiener filter when constructing soft mask matrices. Defaults to 2.0.
        top_db (float, optional): threshold the output at top_db below the peak: max(20 * log10(S/ref)) - top_db. Defaults to 80.0.
        mask (bool, optional): Return the masking matrices instead of components. Defaults to False.
        residual (bool, optional): Whether to return residual components of the audio file or not. Defaults to False.
        dpi (int, optional): Image quality of the rendered figure in DPI. Defaults to 300.
        autoshow (bool, optional): Whether to show the resulting figure automatically (inline, when running in a notebook; a no-op otherwise). Defaults to True.
        original_time (bool, optional): Whether to plot original time or not. This parameter can be useful if the video file has been shortened beforehand (e.g. skip). Defaults to False.
        title (str, optional): Optionally add title to the figure. Possible to set the filename as the title using the string 'filename'. Defaults to None.
        target_name (str, optional): The name of the output image. Defaults to None (which assumes that the input filename with the suffix "_hpss.png" should be used).
        overwrite (bool, optional): Whether to allow overwriting existing files or to automatically increment target filenames to avoid overwriting. Defaults to True.

    Returns:
        MgFigure: An MgFigure object referring to the internal figure and its data.
    """

    if not has_audio(self.filename):
        print('The video has no audio track.')
        return

    target_name = resolve_filename(self.of, '_hpss.png', target_name, overwrite)

    y, sr = self._load()
    if dim == 2:
        D = librosa.feature.melspectrogram(y=y, sr=sr, n_fft=self.n_fft, hop_length=self.hop_length, n_mels=n_mels, fmin=fmin, fmax=fmax)
        # Separate into harmonic and percussive components
        H, P = librosa.decompose.hpss(D, kernel_size=kernel_size, margin=margin, power=power, mask=mask)
    elif dim == 1:
        h, p = librosa.effects.hpss(y)
    else:
        print('MgAudio.hpss() can only be computed on 1 (i.e. waveform) or 2 (i.e. spectrogram) dimensions.')
        return

    if dim == 2:
        if residual:
            fig, ax = plt.subplots(nrows=3, figsize=(12, 8), dpi=dpi, sharex=True)
        else:
            fig, ax = plt.subplots(nrows=2, figsize=(12, 6), dpi=dpi, sharex=True)

        # Display spectrograms
        librosa.display.specshow(
            librosa.amplitude_to_db(np.abs(H), ref=np.max(np.abs(D)), top_db=top_db), sr=sr, hop_length=self.hop_length, 
            fmin=fmin, fmax=fmax, x_axis='time', y_axis='mel', cmap='magma', ax=ax[0]
                            )
        librosa.display.specshow(
            librosa.amplitude_to_db(np.abs(P), ref=np.max(np.abs(D)), top_db=top_db), sr=sr, hop_length=self.hop_length, 
            fmin=fmin, fmax=fmax, x_axis='time', y_axis='mel', cmap='magma', ax=ax[1]
                            )
        ax[0].set(title='Harmonic')
        ax[1].set(title='Percussive')

    else:
        fig, ax = plt.subplots(figsize=(12, 4), dpi=dpi, sharex=True)
        librosa.display.waveshow(
            h, sr=sr, alpha=0.5, label='Harmonic'
                                 )
        librosa.display.waveshow(
            p, sr=sr, alpha=0.5, label='Percussive'
                                 )

    fig.patch.set_facecolor('white') # make sure background is white
    fig.patch.set_alpha(1)

    # add title
    if title is None:
        title = ''
    if title == 'filename':
        title = os.path.basename(self.filename)
    fig.suptitle(title, fontsize=16)

    if residual:
        if dim == 2:
            R = D - (H + P)
            librosa.display.specshow(
                librosa.amplitude_to_db(np.abs(R), ref=np.max(np.abs(D)), top_db=top_db), sr=sr, hop_length=self.hop_length, 
                fmin=fmin, fmax=fmax, x_axis='time', y_axis='mel', cmap='magma', ax=ax[2]
                        )
            ax[2].set(title='Residual')

        else:
            r = y - (h + p)
            librosa.display.waveshow(
                r, sr=sr, alpha=0.5, label='Residual'
                                 )

    # Adapt the plotting of the audio file's time when skipping frames of a video file
    if dim == 2:
        if residual:
            self.format_time(ax[2], original_time)
        else:
            self.format_time(ax[1], original_time)
    else:
        self.format_time(ax, original_time)

    plt.tight_layout()
    plt.savefig(target_name, format='png', transparent=False)

    if dim == 1:
        # Add labels to plot
        plt.legend()

    # Always close the pyplot figure: the returned MgFigure displays the saved
    # PNG via its rich repr, so leaving the figure open would cause the inline
    # backend to render a second (duplicate) copy in notebooks.
    plt.close(fig)

    # create MgFigure
    if dim == 2:
        data = {
            "hop_size": self.hop_length,
            "sr": sr,
            "of": self.of,
            "mel_spectrogram": D,
            "harmonic": H,
            "percussive": P,
        }
    else:
        data = {
            "hop_size": self.hop_length,
            "sr": sr,
            "of": self.of,
            "waveform": y,
            "harmonic": h,
            "percussive": p,
        }

    mgf = MgFigure(
        figure=fig,
        figure_type='audio.hpss',
        data=data,
        layers=None,
        image=target_name)

    return self._autoshow(mgf, autoshow)

descriptors

descriptors(n_mels=128, fmin=0.0, fmax=None, power=2, dpi=300, autoshow=True, original_time=False, title=None, target_name=None, save_data=False, data_format='csv', target_name_data=None, overwrite=True)

Renders a figure of plots showing spectral/loudness descriptors, including RMS energy, spectral flatness, centroid, bandwidth, rolloff of the video/audio file.

Parameters:

Name Type Description Default
n_mels int

The number of mel filters to use for filtering the frequency domain. Affects the vertical resolution (sharpness) of the spectrogram. NB: Too high values with relatively small window sizes can result in artifacts (typically black lines) in the resulting image. Defaults to 128.

128
fmin float

Lowest frequency (in Hz). Defaults to 0.0.

0.0
fmax float

Highest frequency (in Hz). Defaults to None, use fmax = sr / 2.0

None
power float

The steepness of the curve for the color mapping. Defaults to 2.

2
dpi int

Image quality of the rendered figure in DPI. Defaults to 300.

300
autoshow bool

Whether to show the resulting figure automatically (inline, when running in a notebook; a no-op otherwise). Defaults to True.

True
original_time bool

Whether to plot original time or not. This parameter can be useful if the file has been shortened beforehand (e.g. skip). Defaults to False.

False
title str

Optionally add title to the figure. Possible to set the filename as the title using the string 'filename'. Defaults to None.

None
target_name str

The name of the output image. Defaults to None (which assumes that the input filename with the suffix "_descriptors.png" should be used).

None
save_data bool

Whether to also save the per-frame descriptor time series (time, RMS, centroid, bandwidth, rolloff, rolloff_min, flatness) to a data file. Defaults to False.

False
data_format str / list

Format of the saved descriptor data. Accepted values are 'csv', 'tsv' and 'txt'. For multiple formats, use a list, e.g. ['csv', 'txt']. Defaults to 'csv'.

'csv'
target_name_data str

The name of the output data file. Defaults to None (which uses the input filename with the suffix "_descriptors").

None
overwrite bool

Whether to allow overwriting existing files or to automatically increment target filenames to avoid overwriting. Defaults to True.

True

Returns:

Name Type Description
MgFigure MgFigure

An MgFigure object referring to the internal figure and its data.

Source code in musicalgestures/_audio.py
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
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
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
def descriptors(self, n_mels: int = 128, fmin: float = 0.0, fmax: float | None = None, power: int = 2, dpi: int = 300, autoshow: bool = True, original_time: bool = False, title: str | None = None, target_name: str | None = None, save_data: bool = False, data_format: str | list = 'csv', target_name_data: str | None = None, overwrite: bool = True) -> MgFigure:
    """
    Renders a figure of plots showing spectral/loudness descriptors, including RMS energy, spectral flatness, centroid, bandwidth, rolloff of the video/audio file.

    Args:
        n_mels (int, optional): The number of mel filters to use for filtering the frequency domain. Affects the vertical resolution (sharpness) of the spectrogram. NB: Too high values with relatively small window sizes can result in artifacts (typically black lines) in the resulting image. Defaults to 128.
        fmin (float, optional): Lowest frequency (in Hz). Defaults to 0.0.
        fmax (float, optional): Highest frequency (in Hz). Defaults to None, use fmax = sr / 2.0
        power (float, optional): The steepness of the curve for the color mapping. Defaults to 2.
        dpi (int, optional): Image quality of the rendered figure in DPI. Defaults to 300.
        autoshow (bool, optional): Whether to show the resulting figure automatically (inline, when running in a notebook; a no-op otherwise). Defaults to True.
        original_time (bool, optional): Whether to plot original time or not. This parameter can be useful if the file has been shortened beforehand (e.g. skip). Defaults to False.
        title (str, optional): Optionally add title to the figure. Possible to set the filename as the title using the string 'filename'. Defaults to None.
        target_name (str, optional): The name of the output image. Defaults to None (which assumes that the input filename with the suffix "_descriptors.png" should be used).
        save_data (bool, optional): Whether to also save the per-frame descriptor time series (time, RMS, centroid, bandwidth, rolloff, rolloff_min, flatness) to a data file. Defaults to False.
        data_format (str/list, optional): Format of the saved descriptor data. Accepted values are 'csv', 'tsv' and 'txt'. For multiple formats, use a list, e.g. ['csv', 'txt']. Defaults to 'csv'.
        target_name_data (str, optional): The name of the output data file. Defaults to None (which uses the input filename with the suffix "_descriptors").
        overwrite (bool, optional): Whether to allow overwriting existing files or to automatically increment target filenames to avoid overwriting. Defaults to True.

    Returns:
        MgFigure: An MgFigure object referring to the internal figure and its data.
    """
    if not has_audio(self.filename):
        print('The video has no audio track.')
        return

    target_name = resolve_filename(self.of, '_descriptors.png', target_name, overwrite)

    y, sr = self._load()

    cent = librosa.feature.spectral_centroid(
        y=y, sr=sr, n_fft=self.n_fft, hop_length=self.hop_length)
    spec_bw = librosa.feature.spectral_bandwidth(
        y=y, sr=sr, n_fft=self.n_fft, hop_length=self.hop_length)
    flatness = librosa.feature.spectral_flatness(
        y=y, n_fft=self.n_fft, hop_length=self.hop_length)
    rolloff = librosa.feature.spectral_rolloff(
        y=y, sr=sr, n_fft=self.n_fft, hop_length=self.hop_length, roll_percent=0.99)
    rolloff_min = librosa.feature.spectral_rolloff(
        y=y, sr=sr, n_fft=self.n_fft, hop_length=self.hop_length, roll_percent=0.01)
    rms = librosa.feature.rms(
        y=y, frame_length=self.n_fft, hop_length=self.hop_length)

    S = librosa.feature.melspectrogram(
        y=y, sr=sr, n_mels=n_mels, n_fft=self.n_fft, hop_length=self.hop_length, power=power, fmin=fmin, fmax=fmax)

    fig, ax = plt.subplots(figsize=(12, 8), dpi=dpi, nrows=3, sharex=True)
    # add title
    if title is None:
        title = ''
    if title == 'filename':
        title = os.path.basename(self.filename)
    fig.suptitle(title, fontsize=16)

    # make sure background is white
    fig.patch.set_facecolor('white')
    fig.patch.set_alpha(1)

    librosa.display.specshow(librosa.power_to_db(
        S, ref=np.max, top_db=120), sr=sr, y_axis='mel', fmin=fmin, fmax=fmax, x_axis='time', hop_length=self.hop_length, ax=ax[2])

    # get rid of "default" ticks
    ax[2].yaxis.set_minor_locator(matplotlib.ticker.NullLocator())

    # Pin the time axis to the actual spectrogram extent (see spectrogram()).
    xmax = S.shape[1] * self.hop_length / sr
    ax[2].set_xlim(0, xmax)

    freq_ticks = [elem*100 for elem in range(10)]
    freq_ticks = [250]
    freq = 500
    while freq < sr/2:
        freq_ticks.append(freq)
        freq *= 1.5

    freq_ticks = [round(elem, -1) for elem in freq_ticks]
    freq_ticks_labels = [str(round(elem/1000, 1)) +
                         'k' if elem > 1000 else int(round(elem)) for elem in freq_ticks]

    ax[2].set(yticks=(freq_ticks))
    ax[2].set(yticklabels=(freq_ticks_labels))

    times = librosa.times_like(
        cent, sr=sr, n_fft=self.n_fft, hop_length=self.hop_length)

    ax[2].fill_between(times, cent[0] - spec_bw[0], cent[0] +
                       spec_bw[0], alpha=0.5, label='Centroid +- bandwidth')
    ax[2].plot(times, cent.T, label='Centroid', color='y')
    ax[2].plot(times, rolloff[0], label='Roll-off frequency (0.99)')
    ax[2].plot(times, rolloff_min[0], color='r',
               label='Roll-off frequency (0.01)')

    # ax[2].legend(loc='upper left', bbox_to_anchor=(1, 1))
    ax[2].legend(loc='upper right')

    ax[1].plot(times, flatness.T, label='Flatness', color='y')
    # ax[1].legend(loc='upper left', bbox_to_anchor=(1, 1))
    ax[1].legend(loc='upper right')

    ax[0].semilogy(times, rms[0], label='RMS Energy')
    # ax[0].legend(loc='upper left', bbox_to_anchor=(1, 1))
    ax[0].legend(loc='upper right')

    # Adapt the plotting of the audio file's time when skipping frames of a video file
    self.format_time(ax[2], original_time, original_duration=None if original_time else xmax)

    plt.tight_layout()
    plt.savefig(target_name, format='png', transparent=False)

    # Always close the pyplot figure: the returned MgFigure displays the saved
    # PNG via its rich repr, so leaving the figure open would cause the inline
    # backend to render a second (duplicate) copy in notebooks.
    plt.close(fig)

    # create MgFigure
    data = {
        "hop_size": self.hop_length,
        "sr": sr,
        "of": self.of,
        "times": times,
        "S": S,
        "length": self.length,
        "cent": cent,
        "spec_bw": spec_bw,
        "rolloff": rolloff,
        "rolloff_min": rolloff_min,
        "flatness": flatness,
        "rms": rms
    }

    # Optionally save the per-frame descriptor time series to disk
    if save_data:
        columns = {
            'Time': times,
            'RMS': rms[0],
            'Centroid': cent[0],
            'Bandwidth': spec_bw[0],
            'Rolloff': rolloff[0],
            'RolloffMin': rolloff_min[0],
            'Flatness': flatness[0],
        }
        _save_audio_data(self.of + '_descriptors', columns, data_format, target_name_data, overwrite)

    mgf = MgFigure(
        figure=fig,
        figure_type='audio.descriptors',
        data=data,
        layers=None,
        image=target_name)

    return self._autoshow(mgf, autoshow)

chromagram

chromagram(n_chroma=12, norm=np.inf, chroma_type='cqt', cmap='coolwarm', dpi=300, autoshow=True, raw=False, original_time=False, title=None, target_name=None, overwrite=True)

Renders a figure showing the chromagram of the video/audio file.

A chromagram maps audio energy onto the 12 pitch classes (C, C#, D, …, B) over time, making it useful for analysing harmony and chord progressions.

Parameters:

Name Type Description Default
n_chroma int

Number of chroma bins (pitch classes). Defaults to 12.

12
norm float or None

Column-wise normalisation. np.inf gives maximum-norm, 1 gives L1-norm, 2 gives L2-norm, None disables normalisation. Defaults to np.inf.

inf
chroma_type str

Algorithm used to compute the chroma features. 'cqt' — Constant-Q transform (best for music, handles low frequencies well). 'stft' — Short-time Fourier transform (faster, slightly lower pitch resolution). 'cens' — Chroma Energy Normalised Statistics (robust to dynamics and timbre). Defaults to 'cqt'.

'cqt'
cmap str

Matplotlib colormap for the chromagram display. Defaults to 'coolwarm'.

'coolwarm'
dpi int

Image quality of the rendered figure in DPI. Defaults to 300.

300
autoshow bool

Whether to show the resulting figure automatically (inline, when running in a notebook; a no-op otherwise). Defaults to True.

True
raw bool

Whether to show labels and ticks on the plot. Defaults to False.

False
original_time bool

Whether to plot original time or not. Defaults to False.

False
title str

Optionally add title to the figure. Use 'filename' to set the filename as title. Defaults to None.

None
target_name str

The name of the output image. Defaults to None (which assumes that the input filename with the suffix "_chromagram.png" should be used).

None
overwrite bool

Whether to allow overwriting existing files or to automatically increment target filenames to avoid overwriting. Defaults to True.

True

Returns:

Name Type Description
MgFigure MgFigure

An MgFigure object referring to the internal figure and its data.

Source code in musicalgestures/_audio.py
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
def chromagram(self, n_chroma: int = 12, norm: float | None = np.inf, chroma_type: str = 'cqt', cmap: str = 'coolwarm', dpi: int = 300, autoshow: bool = True, raw: bool = False, original_time: bool = False, title: str | None = None, target_name: str | None = None, overwrite: bool = True) -> MgFigure:
    """
    Renders a figure showing the chromagram of the video/audio file.

    A chromagram maps audio energy onto the 12 pitch classes (C, C#, D, …, B) over time,
    making it useful for analysing harmony and chord progressions.

    Args:
        n_chroma (int, optional): Number of chroma bins (pitch classes). Defaults to 12.
        norm (float or None, optional): Column-wise normalisation. np.inf gives maximum-norm,
            1 gives L1-norm, 2 gives L2-norm, None disables normalisation. Defaults to np.inf.
        chroma_type (str, optional): Algorithm used to compute the chroma features.
            'cqt'  — Constant-Q transform (best for music, handles low frequencies well).
            'stft' — Short-time Fourier transform (faster, slightly lower pitch resolution).
            'cens' — Chroma Energy Normalised Statistics (robust to dynamics and timbre).
            Defaults to 'cqt'.
        cmap (str, optional): Matplotlib colormap for the chromagram display. Defaults to 'coolwarm'.
        dpi (int, optional): Image quality of the rendered figure in DPI. Defaults to 300.
        autoshow (bool, optional): Whether to show the resulting figure automatically (inline, when running in a notebook; a no-op otherwise). Defaults to True.
        raw (bool, optional): Whether to show labels and ticks on the plot. Defaults to False.
        original_time (bool, optional): Whether to plot original time or not. Defaults to False.
        title (str, optional): Optionally add title to the figure. Use 'filename' to set the filename as title. Defaults to None.
        target_name (str, optional): The name of the output image. Defaults to None (which assumes that the input filename with the suffix "_chromagram.png" should be used).
        overwrite (bool, optional): Whether to allow overwriting existing files or to automatically increment target filenames to avoid overwriting. Defaults to True.

    Returns:
        MgFigure: An MgFigure object referring to the internal figure and its data.
    """
    if not has_audio(self.filename):
        print('The video has no audio track.')
        return

    target_name = resolve_filename(self.of, '_chromagram.png', target_name, overwrite)

    y, sr = self._load()

    chroma_type = chroma_type.lower()
    if chroma_type == 'cqt':
        chroma = librosa.feature.chroma_cqt(
            y=y, sr=sr, hop_length=self.hop_length, n_chroma=n_chroma, norm=norm)
    elif chroma_type == 'stft':
        chroma = librosa.feature.chroma_stft(
            y=y, sr=sr, n_fft=self.n_fft, hop_length=self.hop_length, n_chroma=n_chroma, norm=norm)
    elif chroma_type == 'cens':
        chroma = librosa.feature.chroma_cens(
            y=y, sr=sr, hop_length=self.hop_length, n_chroma=n_chroma, norm=norm)
    else:
        print(f"Unknown chroma_type '{chroma_type}'. Use 'cqt', 'stft', or 'cens'.")
        return

    fig, ax = plt.subplots(figsize=(12, 4), dpi=dpi)
    fig.patch.set_facecolor('white')
    fig.patch.set_alpha(1)

    if title is None:
        title = ''
    if title == 'filename':
        title = os.path.basename(self.filename)
    fig.suptitle(title, fontsize=16)

    img = librosa.display.specshow(
        chroma, sr=sr, hop_length=self.hop_length,
        x_axis='time', y_axis='chroma', cmap=cmap, ax=ax)

    fig.colorbar(img, ax=ax)
    ax.set(title=f'Chromagram ({chroma_type.upper()})')

    self.format_time(ax, original_time)

    if raw:
        fig.patch.set_visible(False)
        fig.suptitle('')
        ax.axis('off')

    plt.tight_layout()
    plt.savefig(target_name, format='png', transparent=False)

    # Always close the pyplot figure: the returned MgFigure displays the saved
    # PNG via its rich repr, so leaving the figure open would cause the inline
    # backend to render a second (duplicate) copy in notebooks.
    plt.close(fig)

    data = {
        "hop_size": self.hop_length,
        "sr": sr,
        "of": self.of,
        "chroma": chroma,
        "chroma_type": chroma_type,
        "n_chroma": n_chroma,
        "length": self.length,
    }

    mgf = MgFigure(
        figure=fig,
        figure_type='audio.chromagram',
        data=data,
        layers=None,
        image=target_name)

    return self._autoshow(mgf, autoshow)

mfcc

mfcc(n_mfcc=13, cmap='RdBu_r', dpi=300, autoshow=True, raw=False, original_time=False, title=None, target_name=None, overwrite=True)

Renders a figure showing the Mel-frequency cepstral coefficients (MFCCs) of the video/audio file.

MFCCs compactly describe the spectral envelope (timbre) of a sound over time and are widely used as features for audio classification and similarity.

Parameters:

Name Type Description Default
n_mfcc int

Number of MFCCs to compute. Defaults to 13.

13
cmap str

Matplotlib colormap for the display. Defaults to 'RdBu_r'.

'RdBu_r'
dpi int

Image quality of the rendered figure in DPI. Defaults to 300.

300
autoshow bool

Whether to show the resulting figure automatically (inline, when running in a notebook; a no-op otherwise). Defaults to True.

True
raw bool

Whether to show labels and ticks on the plot. Defaults to False.

False
original_time bool

Whether to plot original time or not. Defaults to False.

False
title str

Optionally add title to the figure. Use 'filename' to set the filename as title. Defaults to None.

None
target_name str

The name of the output image. Defaults to None (which assumes that the input filename with the suffix "_mfcc.png" should be used).

None
overwrite bool

Whether to allow overwriting existing files or to automatically increment target filenames to avoid overwriting. Defaults to True.

True

Returns:

Name Type Description
MgFigure MgFigure

An MgFigure object referring to the internal figure and its data.

Source code in musicalgestures/_audio.py
863
864
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
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
def mfcc(self, n_mfcc: int = 13, cmap: str = 'RdBu_r', dpi: int = 300, autoshow: bool = True, raw: bool = False, original_time: bool = False, title: str | None = None, target_name: str | None = None, overwrite: bool = True) -> MgFigure:
    """
    Renders a figure showing the Mel-frequency cepstral coefficients (MFCCs) of the video/audio file.

    MFCCs compactly describe the spectral envelope (timbre) of a sound over time and are
    widely used as features for audio classification and similarity.

    Args:
        n_mfcc (int, optional): Number of MFCCs to compute. Defaults to 13.
        cmap (str, optional): Matplotlib colormap for the display. Defaults to 'RdBu_r'.
        dpi (int, optional): Image quality of the rendered figure in DPI. Defaults to 300.
        autoshow (bool, optional): Whether to show the resulting figure automatically (inline, when running in a notebook; a no-op otherwise). Defaults to True.
        raw (bool, optional): Whether to show labels and ticks on the plot. Defaults to False.
        original_time (bool, optional): Whether to plot original time or not. Defaults to False.
        title (str, optional): Optionally add title to the figure. Use 'filename' to set the filename as title. Defaults to None.
        target_name (str, optional): The name of the output image. Defaults to None (which assumes that the input filename with the suffix "_mfcc.png" should be used).
        overwrite (bool, optional): Whether to allow overwriting existing files or to automatically increment target filenames to avoid overwriting. Defaults to True.

    Returns:
        MgFigure: An MgFigure object referring to the internal figure and its data.
    """
    if not has_audio(self.filename):
        print('The video has no audio track.')
        return

    target_name = resolve_filename(self.of, '_mfcc.png', target_name, overwrite)

    y, sr = self._load()

    mfccs = librosa.feature.mfcc(
        y=y, sr=sr, n_mfcc=n_mfcc, n_fft=self.n_fft, hop_length=self.hop_length)

    fig, ax = plt.subplots(figsize=(12, 4), dpi=dpi)
    fig.patch.set_facecolor('white')
    fig.patch.set_alpha(1)

    if title is None:
        title = ''
    if title == 'filename':
        title = os.path.basename(self.filename)
    fig.suptitle(title, fontsize=16)

    img = librosa.display.specshow(
        mfccs, sr=sr, hop_length=self.hop_length, x_axis='time', cmap=cmap, ax=ax)
    fig.colorbar(img, ax=ax)
    ax.set(ylabel='MFCC coefficient', title='MFCC')

    self.format_time(ax, original_time)

    if raw:
        fig.patch.set_visible(False)
        fig.suptitle('')
        ax.axis('off')

    plt.tight_layout()
    plt.savefig(target_name, format='png', transparent=False)

    # Always close the pyplot figure: the returned MgFigure displays the saved
    # PNG via its rich repr, so leaving the figure open would cause the inline
    # backend to render a second (duplicate) copy in notebooks.
    plt.close(fig)

    data = {
        "hop_size": self.hop_length,
        "sr": sr,
        "of": self.of,
        "mfcc": mfccs,
        "n_mfcc": n_mfcc,
        "length": self.length,
    }

    mgf = MgFigure(
        figure=fig,
        figure_type='audio.mfcc',
        data=data,
        layers=None,
        image=target_name)

    return self._autoshow(mgf, autoshow)

tempo

tempo(dpi=300, autoshow=True, raw=False, original_time=False, title=None, target_name=None, overwrite=True)

Estimates tempo and beat positions, and renders the waveform with beat markers.

Uses librosa's beat tracker. In addition to the figure, the returned object's .data dictionary contains the estimated tempo, beat times, inter-beat intervals, a beat-regularity measure, and circular beat statistics (phase deviation of each beat from a fitted ideal grid, plus a Rayleigh test of timing consistency).

Parameters:

Name Type Description Default
dpi int

Image quality of the rendered figure in DPI. Defaults to 300.

300
autoshow bool

Whether to show the resulting figure automatically (inline, when running in a notebook; a no-op otherwise). Defaults to True.

True
raw bool

Whether to show labels and ticks on the plot. Defaults to False.

False
original_time bool

Whether to plot original time or not. Defaults to False.

False
title str

Optionally add title to the figure. Use 'filename' to set the filename as title. Defaults to None.

None
target_name str

The name of the output image. Defaults to None (which assumes that the input filename with the suffix "_tempo.png" should be used).

None
overwrite bool

Whether to allow overwriting existing files or to automatically increment target filenames to avoid overwriting. Defaults to True.

True

Returns:

Name Type Description
MgFigure MgFigure

An MgFigure object. Access numeric results via .data: 'tempo', 'beat_times', 'ibi', 'beat_regularity', 'beat_phases', 'deviations_s', 'R_beat', 'mu_beat', 'T_fit', 't0_fit', 'p_rayleigh'.

Source code in musicalgestures/_audio.py
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
def tempo(self, dpi: int = 300, autoshow: bool = True, raw: bool = False, original_time: bool = False, title: str | None = None, target_name: str | None = None, overwrite: bool = True) -> MgFigure:
    """
    Estimates tempo and beat positions, and renders the waveform with beat markers.

    Uses librosa's beat tracker. In addition to the figure, the returned object's
    ``.data`` dictionary contains the estimated tempo, beat times, inter-beat
    intervals, a beat-regularity measure, and circular beat statistics (phase
    deviation of each beat from a fitted ideal grid, plus a Rayleigh test of
    timing consistency).

    Args:
        dpi (int, optional): Image quality of the rendered figure in DPI. Defaults to 300.
        autoshow (bool, optional): Whether to show the resulting figure automatically (inline, when running in a notebook; a no-op otherwise). Defaults to True.
        raw (bool, optional): Whether to show labels and ticks on the plot. Defaults to False.
        original_time (bool, optional): Whether to plot original time or not. Defaults to False.
        title (str, optional): Optionally add title to the figure. Use 'filename' to set the filename as title. Defaults to None.
        target_name (str, optional): The name of the output image. Defaults to None (which assumes that the input filename with the suffix "_tempo.png" should be used).
        overwrite (bool, optional): Whether to allow overwriting existing files or to automatically increment target filenames to avoid overwriting. Defaults to True.

    Returns:
        MgFigure: An MgFigure object. Access numeric results via ``.data``:
            'tempo', 'beat_times', 'ibi', 'beat_regularity', 'beat_phases',
            'deviations_s', 'R_beat', 'mu_beat', 'T_fit', 't0_fit', 'p_rayleigh'.
    """
    from musicalgestures._analysis import circular_stats, rayleigh_test

    if not has_audio(self.filename):
        print('The video has no audio track.')
        return

    target_name = resolve_filename(self.of, '_tempo.png', target_name, overwrite)

    y, sr = self._load()

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

    # Beat regularity from inter-beat intervals
    if len(beat_times) > 1:
        ibi = np.diff(beat_times)
        beat_regularity = float(1.0 - ibi.std() / ibi.mean()) if ibi.mean() > 0 else 0.0
    else:
        ibi = np.array([0.0])
        beat_regularity = 0.0

    # Circular beat statistics: fit an ideal grid and measure phase deviations
    if len(beat_times) >= 4:
        k = np.arange(len(beat_times))
        T_fit, t0_fit = np.polyfit(k, beat_times, 1)
        deviations_s = beat_times - (t0_fit + k * T_fit)
        beat_phases = (deviations_s / T_fit) * 2 * np.pi % (2 * np.pi)
        R_beat, mu_beat = circular_stats(beat_phases)
        _, p_rayleigh = rayleigh_test(beat_phases)
    else:
        beat_phases = deviations_s = np.array([])
        T_fit, t0_fit = (60.0 / tempo if tempo > 0 else 0.0), 0.0
        R_beat = mu_beat = 0.0
        p_rayleigh = 1.0

    fig, ax = plt.subplots(figsize=(12, 4), dpi=dpi)
    fig.patch.set_facecolor('white')
    fig.patch.set_alpha(1)

    if title is None:
        title = ''
    if title == 'filename':
        title = os.path.basename(self.filename)
    fig.suptitle(title, fontsize=16)

    librosa.display.waveshow(y, sr=sr, ax=ax, alpha=0.6)
    for bt in beat_times:
        ax.axvline(bt, color='r', alpha=0.6, linewidth=0.8)
    ax.set(title=f'Tempo: {tempo:.1f} BPM   |   Beats: {len(beat_times)}   |   Regularity: {beat_regularity:.1%}')

    self.format_time(ax, original_time)

    if raw:
        fig.patch.set_visible(False)
        fig.suptitle('')
        ax.axis('off')

    plt.tight_layout()
    plt.savefig(target_name, format='png', transparent=False)

    # Always close the pyplot figure: the returned MgFigure displays the saved
    # PNG via its rich repr, so leaving the figure open would cause the inline
    # backend to render a second (duplicate) copy in notebooks.
    plt.close(fig)

    data = {
        "sr": sr,
        "of": self.of,
        "length": self.length,
        "tempo": tempo,
        "beat_times": beat_times,
        "ibi": ibi,
        "beat_regularity": beat_regularity,
        "beat_phases": beat_phases,
        "deviations_s": deviations_s,
        "R_beat": R_beat,
        "mu_beat": mu_beat,
        "T_fit": T_fit,
        "t0_fit": t0_fit,
        "p_rayleigh": p_rayleigh,
    }

    mgf = MgFigure(
        figure=fig,
        figure_type='audio.tempo',
        data=data,
        layers=None,
        image=target_name)

    return self._autoshow(mgf, autoshow)

beat_statistics

beat_statistics(n_bins=32, cmap='YlOrRd', dpi=300, autoshow=True, title=None, target_name=None, overwrite=True)

Renders circular statistics of beat-timing consistency.

Fits an ideal isochronous beat grid to the detected beats and visualises how each beat deviates from it: a polar histogram of beat phases (with the mean resultant vector) and a time series of millisecond deviations. This reveals whether a performer rushes, drags, or keeps steady time.

Parameters:

Name Type Description Default
n_bins int

Number of bins in the polar phase histogram. Defaults to 32.

32
cmap str

Matplotlib colormap for the polar histogram. Defaults to 'YlOrRd'.

'YlOrRd'
dpi int

Image quality of the rendered figure in DPI. Defaults to 300.

300
autoshow bool

Whether to show the resulting figure automatically (inline, when running in a notebook; a no-op otherwise). Defaults to True.

True
title str

Optionally add title to the figure. Use 'filename' to set the filename as title. Defaults to None.

None
target_name str

The name of the output image. Defaults to None (which assumes that the input filename with the suffix "_beatstats.png" should be used).

None
overwrite bool

Whether to allow overwriting existing files or to automatically increment target filenames to avoid overwriting. Defaults to True.

True

Returns:

Name Type Description
MgFigure MgFigure

An MgFigure object whose .data mirrors the beat statistics from tempo(), or None if fewer than four beats are detected.

Source code in musicalgestures/_audio.py
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
def beat_statistics(self, n_bins: int = 32, cmap: str = 'YlOrRd', dpi: int = 300, autoshow: bool = True, title: str | None = None, target_name: str | None = None, overwrite: bool = True) -> MgFigure:
    """
    Renders circular statistics of beat-timing consistency.

    Fits an ideal isochronous beat grid to the detected beats and visualises how
    each beat deviates from it: a polar histogram of beat phases (with the mean
    resultant vector) and a time series of millisecond deviations. This reveals
    whether a performer rushes, drags, or keeps steady time.

    Args:
        n_bins (int, optional): Number of bins in the polar phase histogram. Defaults to 32.
        cmap (str, optional): Matplotlib colormap for the polar histogram. Defaults to 'YlOrRd'.
        dpi (int, optional): Image quality of the rendered figure in DPI. Defaults to 300.
        autoshow (bool, optional): Whether to show the resulting figure automatically (inline, when running in a notebook; a no-op otherwise). Defaults to True.
        title (str, optional): Optionally add title to the figure. Use 'filename' to set the filename as title. Defaults to None.
        target_name (str, optional): The name of the output image. Defaults to None (which assumes that the input filename with the suffix "_beatstats.png" should be used).
        overwrite (bool, optional): Whether to allow overwriting existing files or to automatically increment target filenames to avoid overwriting. Defaults to True.

    Returns:
        MgFigure: An MgFigure object whose ``.data`` mirrors the beat statistics from tempo(),
            or None if fewer than four beats are detected.
    """
    if not has_audio(self.filename):
        print('The video has no audio track.')
        return

    # Reuse tempo() for the beat analysis (without showing its figure)
    beat_mgf = self.tempo(autoshow=False, overwrite=overwrite)
    if beat_mgf is None:
        return
    plt.close(beat_mgf.figure)
    d = beat_mgf.data

    beat_phases = d["beat_phases"]
    if len(beat_phases) < 4:
        print('Not enough beats detected for circular statistics (need at least 4).')
        return

    target_name = resolve_filename(self.of, '_beatstats.png', target_name, overwrite)

    deviations_ms = d["deviations_s"] * 1000
    R, mu = d["R_beat"], d["mu_beat"]

    fig = plt.figure(figsize=(14, 6), dpi=dpi)
    fig.patch.set_facecolor('white')
    fig.patch.set_alpha(1)

    # Polar histogram of beat phases
    ax_p = fig.add_subplot(121, projection='polar')
    bin_edges = np.linspace(0, 2 * np.pi, n_bins + 1)
    counts, _ = np.histogram(beat_phases, bins=bin_edges)
    theta_c = (bin_edges[:-1] + bin_edges[1:]) / 2
    norm_c = counts / (counts.max() + 1e-9)
    ax_p.bar(theta_c, counts, width=2 * np.pi / n_bins * 0.88,
             color=matplotlib.colormaps[cmap](norm_c), alpha=0.85,
             edgecolor='white', linewidth=0.3)
    if counts.max() > 0:
        ax_p.annotate('', xy=(np.radians(mu), R * counts.max()), xytext=(0, 0),
                      arrowprops=dict(arrowstyle='-|>', color='#333333', lw=2.0, mutation_scale=16))
    ax_p.set_xticks([0, np.pi / 2, np.pi, 3 * np.pi / 2])
    ax_p.set_xticklabels(['on beat', '1/4 late', '1/2', '1/4 early'], fontsize=8)
    ax_p.set_title(f'Beat phase deviation\nR = {R:.3f}   μ = {mu:.1f}°   p = {d["p_rayleigh"]:.4f}', fontsize=10)

    # Time series of deviations
    ax_t = fig.add_subplot(122)
    sc = ax_t.scatter(d["beat_times"], deviations_ms, c=d["beat_times"], cmap='plasma', s=18, alpha=0.8)
    ax_t.axhline(0, color='#888888', lw=1.0, ls='--', alpha=0.7)
    ax_t.axhline(float(deviations_ms.mean()), color='#1f77b4', lw=1.2, ls=':',
                 label=f'mean {float(deviations_ms.mean()):.1f} ms')
    ax_t.set(xlabel='Time (s)', ylabel='Deviation from ideal grid (ms)', title='Beat timing deviation')
    ax_t.legend()
    cb = fig.colorbar(sc, ax=ax_t)
    cb.set_label('Time (s)')

    if title is None:
        title = ''
    if title == 'filename':
        title = os.path.basename(self.filename)
    fig.suptitle(f'{title}   Tempo: {d["tempo"]:.1f} BPM   σ = {float(deviations_ms.std()):.1f} ms'.strip(),
                 fontsize=13, fontweight='bold')

    plt.tight_layout(rect=[0, 0, 1, 0.93])
    plt.savefig(target_name, format='png', transparent=False)

    # Always close the pyplot figure: the returned MgFigure displays the saved
    # PNG via its rich repr, so leaving the figure open would cause the inline
    # backend to render a second (duplicate) copy in notebooks.
    plt.close(fig)

    mgf = MgFigure(
        figure=fig,
        figure_type='audio.beat_statistics',
        data=d,
        layers=None,
        image=target_name)

    return self._autoshow(mgf, autoshow)