-
Notifications
You must be signed in to change notification settings - Fork 0
/
pretty_midi.py
1460 lines (1326 loc) · 65.1 KB
/
pretty_midi.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
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
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
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
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
761
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
862
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
942
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
"""Utility functions for handling MIDI data in an easy to read/manipulate
format
"""
from __future__ import print_function
import mido
import numpy as np
import math
import warnings
import collections
import copy
import functools
import six
from heapq import merge
from instrument import Instrument
from containers import (KeySignature, TimeSignature, Lyric, Note,
PitchBend, ControlChange, Text)
from utilities import (key_name_to_key_number, qpm_to_bpm)
# The largest we'd ever expect a tick to be
MAX_TICK = 1e7
class PrettyMIDI(object):
"""A container for MIDI data in an easily-manipulable format.
Parameters
----------
midi_file : str or file
Path or file pointer to a MIDI file.
Default ``None`` which means create an empty class with the supplied
values for resolution and initial tempo.
resolution : int
Resolution of the MIDI data, when no file is provided.
initial_tempo : float
Initial tempo for the MIDI data, when no file is provided.
Attributes
----------
instruments : list
List of :class:`pretty_midi.Instrument` objects.
key_signature_changes : list
List of :class:`pretty_midi.KeySignature` objects.
time_signature_changes : list
List of :class:`pretty_midi.TimeSignature` objects.
lyrics : list
List of :class:`pretty_midi.Lyric` objects.
text_events : list
List of :class:`pretty_midi.Text` objects.
"""
def __init__(self, midi_file=None, resolution=220, initial_tempo=120.):
"""Initialize either by populating it with MIDI data from a file or
from scratch with no data.
"""
if midi_file is not None:
# Load in the MIDI data using the midi module
if isinstance(midi_file, six.string_types):
# If a string was given, pass it as the string filename
midi_data = mido.MidiFile(filename=midi_file)
else:
# Otherwise, try passing it in as a file pointer
midi_data = mido.MidiFile(file=midi_file)
# Convert tick values in midi_data to absolute, a useful thing.
for track in midi_data.tracks:
tick = 0
for event in track:
event.time += tick
tick = event.time
# Store the resolution for later use
self.resolution = midi_data.ticks_per_beat
# Populate the list of tempo changes (tick scales)
self._load_tempo_changes(midi_data)
# Update the array which maps ticks to time
max_tick = max([max([e.time for e in t])
for t in midi_data.tracks]) + 1
# If max_tick is huge, the MIDI file is probably corrupt
# and creating the __tick_to_time array will thrash memory
if max_tick > MAX_TICK:
raise ValueError(('MIDI file has a largest tick of {},'
' it is likely corrupt'.format(max_tick)))
# Create list that maps ticks to time in seconds
self._update_tick_to_time(max_tick)
# Populate the list of key and time signature changes
self._load_metadata(midi_data)
# Check that there are tempo, key and time change events
# only on track 0
if any(e.type in ('set_tempo', 'key_signature', 'time_signature')
for track in midi_data.tracks[1:] for e in track):
warnings.warn(
"Tempo, Key or Time signature change events found on "
"non-zero tracks. This is not a valid type 0 or type 1 "
"MIDI file. Tempo, Key or Time Signature may be wrong.",
RuntimeWarning)
# Populate the list of instruments
self._load_instruments(midi_data)
else:
self.resolution = resolution
# Compute the tick scale for the provided initial tempo
# and let the tick scale start from 0
self._tick_scales = [(0, 60.0/(initial_tempo*self.resolution))]
# Only need to convert one tick to time
self.__tick_to_time = [0]
# Empty instruments list
self.instruments = []
# Empty key signature changes list
self.key_signature_changes = []
# Empty time signatures changes list
self.time_signature_changes = []
# Empty lyrics list
self.lyrics = []
# Empty text events list
self.text_events = []
def _load_tempo_changes(self, midi_data):
"""Populates ``self._tick_scales`` with tuples of
``(tick, tick_scale)`` loaded from ``midi_data``.
Parameters
----------
midi_data : midi.FileReader
MIDI object from which data will be read.
"""
# MIDI data is given in "ticks".
# We need to convert this to clock seconds.
# The conversion factor involves the BPM, which may change over time.
# So, create a list of tuples, (time, tempo)
# denoting a tempo change at a certain time.
# By default, set the tempo to 120 bpm, starting at time 0
self._tick_scales = [(0, 60.0/(120.0*self.resolution))]
# For SMF file type 0, all events are on track 0.
# For type 1, all tempo events should be on track 1.
# Everyone ignores type 2.
# So, just look at events on track 0
for event in midi_data.tracks[0]:
if event.type == 'set_tempo':
# Only allow one tempo change event at the beginning
if event.time == 0:
bpm = 6e7/event.tempo
self._tick_scales = [(0, 60.0/(bpm*self.resolution))]
else:
# Get time and BPM up to this point
_, last_tick_scale = self._tick_scales[-1]
tick_scale = 60.0/((6e7/event.tempo)*self.resolution)
# Ignore repetition of BPM, which happens often
if tick_scale != last_tick_scale:
self._tick_scales.append((event.time, tick_scale))
def _load_metadata(self, midi_data):
"""Populates ``self.time_signature_changes`` with ``TimeSignature``
objects, ``self.key_signature_changes`` with ``KeySignature`` objects,
``self.lyrics`` with ``Lyric`` objects and ``self.text_events`` with
``Text`` objects.
Parameters
----------
midi_data : midi.FileReader
MIDI object from which data will be read.
"""
# Initialize empty lists for storing key signature changes, time
# signature changes, and lyrics
self.key_signature_changes = []
self.time_signature_changes = []
self.lyrics = []
self.text_events = []
for event in midi_data.tracks[0]:
if event.type == 'key_signature':
key_obj = KeySignature(
key_name_to_key_number(event.key),
self.__tick_to_time[event.time])
self.key_signature_changes.append(key_obj)
elif event.type == 'time_signature':
ts_obj = TimeSignature(event.numerator,
event.denominator,
self.__tick_to_time[event.time])
self.time_signature_changes.append(ts_obj)
# We search for lyrics and text events on all tracks
# Lists of lyrics and text events lists, for every track
tracks_with_lyrics = []
tracks_with_text_events = []
for track in midi_data.tracks:
# Track specific lists that get appended if not empty
lyrics = []
text_events = []
for event in track:
if event.type == 'lyrics':
lyrics.append(Lyric(
event.text, self.__tick_to_time[event.time]))
elif event.type == 'text':
text_events.append(Text(
event.text, self.__tick_to_time[event.time]))
if lyrics:
tracks_with_lyrics.append(lyrics)
if text_events:
tracks_with_text_events.append(text_events)
# We merge the already sorted lists for every track, based on time
self.lyrics = list(merge(*tracks_with_lyrics, key=lambda x: x.time))
self.text_events = list(merge(*tracks_with_text_events, key=lambda x: x.time))
def _update_tick_to_time(self, max_tick):
"""Creates ``self.__tick_to_time``, a class member array which maps
ticks to time starting from tick 0 and ending at ``max_tick``.
Parameters
----------
max_tick : int
Last tick to compute time for. If ``self._tick_scales`` contains a
tick which is larger than this value, it will be used instead.
"""
# If max_tick is smaller than the largest tick in self._tick_scales,
# use this largest tick instead
max_scale_tick = max(ts[0] for ts in self._tick_scales)
max_tick = max_tick if max_tick > max_scale_tick else max_scale_tick
# Allocate tick to time array - indexed by tick from 0 to max_tick
self.__tick_to_time = np.zeros(max_tick + 1)
# Keep track of the end time of the last tick in the previous interval
last_end_time = 0
# Cycle through intervals of different tempi
for (start_tick, tick_scale), (end_tick, _) in \
zip(self._tick_scales[:-1], self._tick_scales[1:]):
# Convert ticks in this interval to times
ticks = np.arange(end_tick - start_tick + 1)
self.__tick_to_time[start_tick:end_tick + 1] = (last_end_time +
tick_scale*ticks)
# Update the time of the last tick in this interval
last_end_time = self.__tick_to_time[end_tick]
# For the final interval, use the final tempo setting
# and ticks from the final tempo setting until max_tick
start_tick, tick_scale = self._tick_scales[-1]
ticks = np.arange(max_tick + 1 - start_tick)
self.__tick_to_time[start_tick:] = (last_end_time +
tick_scale*ticks)
def _load_instruments(self, midi_data):
"""Populates ``self.instruments`` using ``midi_data``.
Parameters
----------
midi_data : midi.FileReader
MIDI object from which data will be read.
"""
# MIDI files can contain a collection of tracks; each track can have
# events occuring on one of sixteen channels, and events can correspond
# to different instruments according to the most recently occurring
# program number. So, we need a way to keep track of which instrument
# is playing on each track on each channel. This dict will map from
# program number, drum/not drum, channel, and track index to instrument
# indices, which we will retrieve/populate using the __get_instrument
# function below.
instrument_map = collections.OrderedDict()
# Store a similar mapping to instruments storing "straggler events",
# e.g. events which appear before we want to initialize an Instrument
stragglers = {}
# This dict will map track indices to any track names encountered
track_name_map = collections.defaultdict(str)
def __get_instrument(program, channel, track, create_new):
"""Gets the Instrument corresponding to the given program number,
drum/non-drum type, channel, and track index. If no such
instrument exists, one is created.
"""
# If we have already created an instrument for this program
# number/track/channel, return it
if (program, channel, track) in instrument_map:
return instrument_map[(program, channel, track)]
# If there's a straggler instrument for this instrument and we
# aren't being requested to create a new instrument
if not create_new and (channel, track) in stragglers:
return stragglers[(channel, track)]
# If we are told to, create a new instrument and store it
if create_new:
is_drum = (channel == 9)
instrument = Instrument(
program, is_drum, track_name_map[track_idx])
# If any events appeared for this instrument before now,
# include them in the new instrument
if (channel, track) in stragglers:
straggler = stragglers[(channel, track)]
instrument.control_changes = straggler.control_changes
instrument.pitch_bends = straggler.pitch_bends
# Add the instrument to the instrument map
instrument_map[(program, channel, track)] = instrument
# Otherwise, create a "straggler" instrument which holds events
# which appear before we actually want to create a proper new
# instrument
else:
# Create a "straggler" instrument
instrument = Instrument(program, track_name_map[track_idx])
# Note that stragglers ignores program number, because we want
# to store all events on a track which appear before the first
# note-on, regardless of program
stragglers[(channel, track)] = instrument
return instrument
for track_idx, track in enumerate(midi_data.tracks):
# Keep track of last note on location:
# key = (instrument, note),
# value = (note-on tick, velocity)
last_note_on = collections.defaultdict(list)
# Keep track of which instrument is playing in each channel
# initialize to program 0 for all channels
current_instrument = np.zeros(16, dtype=np.int)
for event in track:
# Look for track name events
if event.type == 'track_name':
# Set the track name for the current track
track_name_map[track_idx] = event.name
# Look for program change events
if event.type == 'program_change':
# Update the instrument for this channel
current_instrument[event.channel] = event.program
# Note ons are note on events with velocity > 0
elif event.type == 'note_on' and event.velocity > 0:
# Store this as the last note-on location
note_on_index = (event.channel, event.note)
last_note_on[note_on_index].append((
event.time, event.velocity))
# Note offs can also be note on events with 0 velocity
elif event.type == 'note_off' or (event.type == 'note_on' and
event.velocity == 0):
# Check that a note-on exists (ignore spurious note-offs)
key = (event.channel, event.note)
if key in last_note_on:
# Get the start/stop times and velocity of every note
# which was turned on with this instrument/drum/pitch.
# One note-off may close multiple note-on events from
# previous ticks. In case there's a note-off and then
# note-on at the same tick we keep the open note from
# this tick.
end_tick = event.time
open_notes = last_note_on[key]
notes_to_close = [
(start_tick, velocity)
for start_tick, velocity in open_notes
if start_tick != end_tick]
notes_to_keep = [
(start_tick, velocity)
for start_tick, velocity in open_notes
if start_tick == end_tick]
for start_tick, velocity in notes_to_close:
start_time = self.__tick_to_time[start_tick]
end_time = self.__tick_to_time[end_tick]
# Create the note event
note = Note(velocity, event.note, start_time,
end_time)
# Get the program and drum type for the current
# instrument
program = current_instrument[event.channel]
# Retrieve the Instrument instance for the current
# instrument
# Create a new instrument if none exists
instrument = __get_instrument(
program, event.channel, track_idx, 1)
# Add the note event
instrument.notes.append(note)
if len(notes_to_close) > 0 and len(notes_to_keep) > 0:
# Note-on on the same tick but we already closed
# some previous notes -> it will continue, keep it.
last_note_on[key] = notes_to_keep
else:
# Remove the last note on for this instrument
del last_note_on[key]
# Store pitch bends
elif event.type == 'pitchwheel':
# Create pitch bend class instance
bend = PitchBend(event.pitch,
self.__tick_to_time[event.time])
# Get the program for the current inst
program = current_instrument[event.channel]
# Retrieve the Instrument instance for the current inst
# Don't create a new instrument if none exists
instrument = __get_instrument(
program, event.channel, track_idx, 0)
# Add the pitch bend event
instrument.pitch_bends.append(bend)
# Store control changes
elif event.type == 'control_change':
control_change = ControlChange(
event.control, event.value,
self.__tick_to_time[event.time])
# Get the program for the current inst
program = current_instrument[event.channel]
# Retrieve the Instrument instance for the current inst
# Don't create a new instrument if none exists
instrument = __get_instrument(
program, event.channel, track_idx, 0)
# Add the control change event
instrument.control_changes.append(control_change)
# Initialize list of instruments from instrument_map
self.instruments = [i for i in instrument_map.values()]
def get_tempo_changes(self):
"""Return arrays of tempo changes in quarter notes-per-minute and their
times.
Returns
-------
tempo_change_times : np.ndarray
Times, in seconds, where the tempo changes.
tempi : np.ndarray
What the tempo is, in quarter notes-per-minute, at each point in
time in ``tempo_change_times``.
"""
# Pre-allocate return arrays
tempo_change_times = np.zeros(len(self._tick_scales))
tempi = np.zeros(len(self._tick_scales))
for n, (tick, tick_scale) in enumerate(self._tick_scales):
# Convert tick of this tempo change to time in seconds
tempo_change_times[n] = self.tick_to_time(tick)
# Convert tick scale to a tempo
tempi[n] = 60.0/(tick_scale*self.resolution)
return tempo_change_times, tempi
def get_end_time(self):
"""Returns the time of the end of the MIDI object (time of the last
event in all instruments/meta-events).
Returns
-------
end_time : float
Time, in seconds, where this MIDI file ends.
"""
# Get end times from all instruments, and times of all meta-events
meta_events = [self.time_signature_changes, self.key_signature_changes,
self.lyrics, self.text_events]
times = ([i.get_end_time() for i in self.instruments] +
[e.time for m in meta_events for e in m] +
self.get_tempo_changes()[0].tolist())
# If there are no events, return 0
if len(times) == 0:
return 0.
else:
return max(times)
def estimate_tempi(self):
"""Return an empirical estimate of tempos and each tempo's probability.
Based on "Automatic Extraction of Tempo and Beat from Expressive
Performance", Dixon 2001.
Returns
-------
tempos : np.ndarray
Array of estimated tempos, in beats per minute.
probabilities : np.ndarray
Array of the probabilities of each tempo estimate.
"""
# Grab the list of onsets
onsets = self.get_onsets()
# Compute inner-onset intervals
ioi = np.diff(onsets)
# "Rhythmic information is provided by IOIs in the range of
# approximately 50ms to 2s (Handel, 1989)"
ioi = ioi[ioi > .05]
ioi = ioi[ioi < 2]
# Normalize all iois into the range 30...300bpm
for n in range(ioi.shape[0]):
while ioi[n] < .2:
ioi[n] *= 2
# Array of inner onset interval cluster means
clusters = np.array([])
# Number of iois in each cluster
cluster_counts = np.array([])
for interval in ioi:
# If this ioi falls within a cluster (threshold is 25ms)
if (np.abs(clusters - interval) < .025).any():
k = np.argmin(clusters - interval)
# Update cluster mean
clusters[k] = (cluster_counts[k]*clusters[k] +
interval)/(cluster_counts[k] + 1)
# Update number of elements in cluster
cluster_counts[k] += 1
# No cluster is close, make a new one
else:
clusters = np.append(clusters, interval)
cluster_counts = np.append(cluster_counts, 1.)
# Sort the cluster list by count
cluster_sort = np.argsort(cluster_counts)[::-1]
clusters = clusters[cluster_sort]
cluster_counts = cluster_counts[cluster_sort]
# Normalize the cluster scores
cluster_counts /= cluster_counts.sum()
return 60./clusters, cluster_counts
def estimate_tempo(self):
"""Returns the best tempo estimate from
:func:`pretty_midi.PrettyMIDI.estimate_tempi()`, for convenience.
Returns
-------
tempo : float
Estimated tempo, in bpm
"""
tempi = self.estimate_tempi()[0]
if tempi.size == 0:
raise ValueError("Can't provide a global tempo estimate when there"
" are fewer than two notes.")
return tempi[0]
def get_beats(self, start_time=0.):
"""Returns a list of beat locations, according to MIDI tempo changes.
For compound meters (any whose numerator is a multiple of 3 greater
than 3), this method returns every third denominator note (for 6/8
or 6/16 time, for example, it will return every third 8th note or
16th note, respectively). For all other meters, this method returns
every denominator note (every quarter note for 3/4 or 4/4 time, for
example).
Parameters
----------
start_time : float
Location of the first beat, in seconds.
Returns
-------
beats : np.ndarray
Beat locations, in seconds.
"""
# Get tempo changes and tempos
tempo_change_times, tempi = self.get_tempo_changes()
# Create beat list; first beat is at first onset
beats = [start_time]
# Index of the tempo we're using
tempo_idx = 0
# Move past all the tempo changes up to the supplied start time
while (tempo_idx < tempo_change_times.shape[0] - 1 and
beats[-1] > tempo_change_times[tempo_idx + 1]):
tempo_idx += 1
# Logic requires that time signature changes are sorted by time
self.time_signature_changes.sort(key=lambda ts: ts.time)
# Index of the time signature change we're using
ts_idx = 0
# Move past all time signature changes up to the supplied start time
while (ts_idx < len(self.time_signature_changes) - 1 and
beats[-1] >= self.time_signature_changes[ts_idx + 1].time):
ts_idx += 1
def get_current_bpm():
''' Convenience function which computs the current BPM based on the
current tempo change and time signature events '''
# When there are time signature changes, use them to compute BPM
if self.time_signature_changes:
return qpm_to_bpm(
tempi[tempo_idx],
self.time_signature_changes[ts_idx].numerator,
self.time_signature_changes[ts_idx].denominator)
# Otherwise, just use the raw tempo change event tempo
else:
return tempi[tempo_idx]
def gt_or_close(a, b):
''' Returns True if a > b or a is close to b '''
return a > b or np.isclose(a, b)
# Get track end time
end_time = self.get_end_time()
# Add beats in
while beats[-1] < end_time:
# Update the current bpm
bpm = get_current_bpm()
# Compute expected beat location, one period later
next_beat = beats[-1] + 60.0/bpm
# If the beat location passes a tempo change boundary...
if (tempo_idx < tempo_change_times.shape[0] - 1 and
next_beat > tempo_change_times[tempo_idx + 1]):
# Start by setting the beat location to the current beat...
next_beat = beats[-1]
# with the entire beat remaining
beat_remaining = 1.0
# While a beat with the current tempo would pass a tempo
# change boundary...
while (tempo_idx < tempo_change_times.shape[0] - 1 and
next_beat + beat_remaining*60.0/bpm >=
tempo_change_times[tempo_idx + 1]):
# Compute the amount the beat location overshoots
overshot_ratio = (tempo_change_times[tempo_idx + 1] -
next_beat)/(60.0/bpm)
# Add in the amount of the beat during this tempo
next_beat += overshot_ratio*60.0/bpm
# Less of the beat remains now
beat_remaining -= overshot_ratio
# Increment the tempo index
tempo_idx = tempo_idx + 1
# Update the current bpm
bpm = get_current_bpm()
# Add in the remainder of the beat at the current tempo
next_beat += beat_remaining*60./bpm
# Check if we have just passed the first time signature change
if self.time_signature_changes and ts_idx == 0:
current_ts_time = self.time_signature_changes[ts_idx].time
if (current_ts_time > beats[-1] and
gt_or_close(next_beat, current_ts_time)):
# Set the next beat to the time signature change time
next_beat = current_ts_time
# If the next beat location passes the next time signature change
# boundary
if ts_idx < len(self.time_signature_changes) - 1:
# Time of the next time signature change
next_ts_time = self.time_signature_changes[ts_idx + 1].time
if gt_or_close(next_beat, next_ts_time):
# Set the next beat to the time signature change time
next_beat = next_ts_time
# Update the time signature index
ts_idx += 1
# Update the current bpm
bpm = get_current_bpm()
beats.append(next_beat)
# The last beat will pass the end_time barrier, so don't include it
beats = np.array(beats[:-1])
return beats
def estimate_beat_start(self, candidates=10, tolerance=.025):
"""Estimate the location of the first beat based on which of the first
few onsets results in the best correlation with the onset spike train.
Parameters
----------
candidates : int
Number of candidate onsets to try.
tolerance : float
The tolerance in seconds around which onsets will be used to
treat a beat as correct.
Returns
-------
beat_start : float
The offset which is chosen as the beat start location.
"""
# Get a sorted list of all notes from all instruments
note_list = [n for i in self.instruments for n in i.notes]
if not note_list:
raise ValueError(
"Can't estimate beat start when there are no notes.")
note_list.sort(key=lambda note: note.start)
# List of possible beat trackings
beat_candidates = []
# List of start times for each beat candidate
start_times = []
onset_index = 0
# Try the first 10 (unique) onsets as beat tracking start locations
while (len(beat_candidates) <= candidates and
len(beat_candidates) <= len(note_list) and
onset_index < len(note_list)):
# Make sure we are using a new start location
if onset_index == 0 or np.abs(note_list[onset_index - 1].start -
note_list[onset_index].start) > .001:
beat_candidates.append(
self.get_beats(note_list[onset_index].start))
start_times.append(note_list[onset_index].start)
onset_index += 1
# Compute onset scores
onset_scores = np.zeros(len(beat_candidates))
# Synthesize note onset signal, with velocity-valued spikes at onsets
fs = 1000
onset_signal = np.zeros(int(fs*(self.get_end_time() + 1)))
for note in note_list:
onset_signal[int(note.start*fs)] += note.velocity
for n, beats in enumerate(beat_candidates):
# Create a synthetic beat signal with 25ms windows
beat_signal = np.zeros(int(fs*(self.get_end_time() + 1)))
for beat in np.append(0, beats):
if beat - tolerance < 0:
beat_window = np.ones(
int(fs*2*tolerance + (beat - tolerance)*fs))
beat_signal[:int((beat + tolerance)*fs)] = beat_window
else:
beat_start = int((beat - tolerance)*fs)
beat_end = beat_start + int(fs*tolerance*2)
beat_window = np.ones(int(fs*tolerance*2))
beat_signal[beat_start:beat_end] = beat_window
# Compute their dot product and normalize to get score
onset_scores[n] = np.dot(beat_signal, onset_signal)/beats.shape[0]
# Return the best-scoring beat start
return start_times[np.argmax(onset_scores)]
def get_downbeats(self, start_time=0.):
"""Return a list of downbeat locations, according to MIDI tempo changes
and time signature change events.
Parameters
----------
start_time : float
Location of the first downbeat, in seconds.
Returns
-------
downbeats : np.ndarray
Downbeat locations, in seconds.
"""
# Get beat locations
beats = self.get_beats(start_time)
# Make a copy of time signatures as we will be manipulating it
time_signatures = copy.deepcopy(self.time_signature_changes)
# If there are no time signatures or they start after 0s, add a 4/4
# signature at time 0
if not time_signatures or time_signatures[0].time > start_time:
time_signatures.insert(0, TimeSignature(4, 4, start_time))
def index(array, value, default):
""" Returns the first index of a value in an array, or `default` if
the value doesn't appear in the array."""
idx = np.flatnonzero(np.isclose(array, value))
if idx.size > 0:
return idx[0]
else:
return default
downbeats = []
end_beat_idx = 0
# Iterate over spans of time signatures
for start_ts, end_ts in zip(time_signatures[:-1], time_signatures[1:]):
# Get index of first beat at start_ts.time, or else use first beat
start_beat_idx = index(beats, start_ts.time, 0)
# Get index of first beat at end_ts.time, or else use last beat
end_beat_idx = index(beats, end_ts.time, start_beat_idx)
# Add beats within this time signature range, skipping beats
# according to the current time signature
if start_ts.numerator % 3 == 0 and start_ts.numerator != 3:
downbeats.append(beats[
start_beat_idx:end_beat_idx:(start_ts.numerator // 3)])
else:
downbeats.append(beats[
start_beat_idx:end_beat_idx:start_ts.numerator])
# Add in beats from the second-to-last to last time signature
final_ts = time_signatures[-1]
start_beat_idx = index(beats, final_ts.time, end_beat_idx)
if final_ts.numerator % 3 == 0 and final_ts.numerator != 3:
downbeats.append(beats[start_beat_idx::(final_ts.numerator // 3)])
else:
downbeats.append(beats[start_beat_idx::final_ts.numerator])
# Convert from list to array
downbeats = np.concatenate(downbeats)
# Return all downbeats after start_time
return downbeats[downbeats >= start_time]
def get_onsets(self):
"""Return a sorted list of the times of all onsets of all notes from
all instruments. May have duplicate entries.
Returns
-------
onsets : np.ndarray
Onset locations, in seconds.
"""
onsets = np.array([])
# Just concatenate onsets from all the instruments
for instrument in self.instruments:
onsets = np.append(onsets, instrument.get_onsets())
# Return them sorted (because why not?)
return np.sort(onsets)
def get_piano_roll(self, fs=100, times=None, pedal_threshold=64):
"""Compute a piano roll matrix of the MIDI data.
Parameters
----------
fs : int
Sampling frequency of the columns, i.e. each column is spaced apart
by ``1./fs`` seconds.
times : np.ndarray
Times of the start of each column in the piano roll.
Default ``None`` which is ``np.arange(0, get_end_time(), 1./fs)``.
pedal_threshold : int
Value of control change 64 (sustain pedal) message that is less
than this value is reflected as pedal-off. Pedals will be
reflected as elongation of notes in the piano roll.
If None, then CC64 message is ignored.
Default is 64.
Returns
-------
piano_roll : np.ndarray, shape=(128,times.shape[0])
Piano roll of MIDI data, flattened across instruments.
"""
# If there are no instruments, return an empty array
if len(self.instruments) == 0:
return np.zeros((128, 0))
# Get piano rolls for each instrument
piano_rolls = [i.get_piano_roll(fs=fs, times=times,
pedal_threshold=pedal_threshold)
for i in self.instruments]
# Allocate piano roll,
# number of columns is max of # of columns in all piano rolls
piano_roll = np.zeros((128, np.max([p.shape[1] for p in piano_rolls])))
# Sum each piano roll into the aggregate piano roll
for roll in piano_rolls:
piano_roll[:, :roll.shape[1]] += roll
return piano_roll
def get_pitch_class_histogram(self, use_duration=False,
use_velocity=False, normalize=True):
"""Computes the histogram of pitch classes.
Parameters
----------
use_duration : bool
Weight frequency by note duration.
use_velocity : bool
Weight frequency by note velocity.
normalize : bool
Normalizes the histogram such that the sum of bin values is 1.
Returns
-------
histogram : np.ndarray, shape=(12,)
Histogram of pitch classes given all tracks, optionally weighted
by their durations or velocities.
"""
# Sum up all histograms from all instruments defaulting to np.zeros(12)
histogram = sum([
i.get_pitch_class_histogram(use_duration, use_velocity)
for i in self.instruments], np.zeros(12))
# Normalize accordingly
if normalize:
histogram /= (histogram.sum() + (histogram.sum() == 0))
return histogram
def get_pitch_class_transition_matrix(self, normalize=False,
time_thresh=0.05):
"""Computes the total pitch class transition matrix of all instruments.
Transitions are added whenever the end of a note is within
``time_thresh`` from the start of any other note.
Parameters
----------
normalize : bool
Normalize transition matrix such that matrix sum equals is 1.
time_thresh : float
Maximum temporal threshold, in seconds, between the start of a note
and end time of any other note for a transition to be added.
Returns
-------
pitch_class_transition_matrix : np.ndarray, shape=(12,12)
Pitch class transition matrix.
"""
# Sum up all matrices from all instruments defaulting zeros matrix
pc_trans_mat = sum(
[i.get_pitch_class_transition_matrix(normalize, time_thresh)
for i in self.instruments], np.zeros((12, 12)))
# Normalize accordingly
if normalize:
pc_trans_mat /= (pc_trans_mat.sum() + (pc_trans_mat.sum() == 0))
return pc_trans_mat
def get_chroma(self, fs=100, times=None, pedal_threshold=64):
"""Get the MIDI data as a sequence of chroma vectors.
Parameters
----------
fs : int
Sampling frequency of the columns, i.e. each column is spaced apart
by ``1./fs`` seconds.
times : np.ndarray
Times of the start of each column in the piano roll.
Default ``None`` which is ``np.arange(0, get_end_time(), 1./fs)``.
pedal_threshold : int
Value of control change 64 (sustain pedal) message that is less
than this value is reflected as pedal-off. Pedals will be
reflected as elongation of notes in the piano roll.
If None, then CC64 message is ignored.
Default is 64.
Returns
-------
piano_roll : np.ndarray, shape=(12,times.shape[0])
Chromagram of MIDI data, flattened across instruments.
"""
# First, get the piano roll
piano_roll = self.get_piano_roll(fs=fs, times=times,
pedal_threshold=pedal_threshold)
# Fold into one octave
chroma_matrix = np.zeros((12, piano_roll.shape[1]))
for note in range(12):
chroma_matrix[note, :] = np.sum(piano_roll[note::12], axis=0)
return chroma_matrix
def synthesize(self, fs=44100, wave=np.sin):
"""Synthesize the pattern using some waveshape. Ignores drum track.
Parameters
----------
fs : int
Sampling rate of the synthesized audio signal.
wave : function
Function which returns a periodic waveform,
e.g. ``np.sin``, ``scipy.signal.square``, etc.
Returns
-------
synthesized : np.ndarray
Waveform of the MIDI data, synthesized at ``fs``.
"""
# If there are no instruments, return an empty array
if len(self.instruments) == 0:
return np.array([])
# Get synthesized waveform for each instrument
waveforms = [i.synthesize(fs=fs, wave=wave) for i in self.instruments]
# Allocate output waveform, with #sample = max length of all waveforms
synthesized = np.zeros(np.max([w.shape[0] for w in waveforms]))
# Sum all waveforms in
for waveform in waveforms:
synthesized[:waveform.shape[0]] += waveform
# Normalize
synthesized /= np.abs(synthesized).max()
return synthesized
def fluidsynth(self, fs=44100, sf2_path=None):
"""Synthesize using fluidsynth.
Parameters
----------
fs : int
Sampling rate to synthesize at.
sf2_path : str
Path to a .sf2 file.
Default ``None``, which uses the TimGM6mb.sf2 file included with
``pretty_midi``.
Returns
-------
synthesized : np.ndarray
Waveform of the MIDI data, synthesized at ``fs``.
"""
# If there are no instruments, or all instruments have no notes, return
# an empty array
if len(self.instruments) == 0 or all(len(i.notes) == 0
for i in self.instruments):
return np.array([])
# Get synthesized waveform for each instrument
waveforms = [i.fluidsynth(fs=fs,
sf2_path=sf2_path) for i in self.instruments]
# Allocate output waveform, with #sample = max length of all waveforms
synthesized = np.zeros(np.max([w.shape[0] for w in waveforms]))
# Sum all waveforms in
for waveform in waveforms:
synthesized[:waveform.shape[0]] += waveform
# Normalize
synthesized /= np.abs(synthesized).max()
return synthesized
def tick_to_time(self, tick):
"""Converts from an absolute tick to time in seconds using
``self.__tick_to_time``.
Parameters
----------
tick : int
Absolute tick to convert.
Returns
-------
time : float
Time in seconds of tick.
"""
# Check that the tick isn't too big