muscriptor 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,344 @@
1
+ """Note and NoteEvent types, tokenizer utilities.
2
+
3
+ Adapted from YourMT3+ (https://github.com/mimbres/YourMT3).
4
+ """
5
+
6
+ import os
7
+ from collections import Counter
8
+ from dataclasses import dataclass
9
+
10
+ from mido import Message, MidiFile, MidiTrack, second2tick
11
+
12
+ DRUM_PROGRAM = 128
13
+ MINIMUM_NOTE_DURATION_SEC = 0.01
14
+
15
+
16
+ @dataclass
17
+ class Note:
18
+ is_drum: bool
19
+ program: int # MIDI program number (0-127); 128 for drum
20
+ onset: float # onset time in seconds
21
+ offset: float # offset time in seconds (== onset for drums)
22
+ pitch: int # MIDI note number (0-127)
23
+
24
+
25
+ @dataclass
26
+ class NoteEvent:
27
+ is_drum: bool
28
+ program: int # [0, 127], 128 for drum (ignored in tokenizer)
29
+ time: float # absolute time in seconds
30
+ velocity: int # 1 for onset, 0 for offset; drum has no offset
31
+ pitch: int # MIDI pitch
32
+
33
+
34
+ @dataclass
35
+ class TieNoteEvent:
36
+ program: int # [0, 127], 128 for drum (ignored in tokenizer)
37
+ pitch: int # MIDI pitch
38
+
39
+
40
+ @dataclass
41
+ class EventRange:
42
+ type: str
43
+ min_value: int
44
+ max_value: int # inclusive
45
+
46
+
47
+ @dataclass
48
+ class Event:
49
+ type: str
50
+ value: int
51
+
52
+
53
+ def sort_notes(notes: list[Note]):
54
+ if len(notes) > 0:
55
+ notes.sort(key=lambda n: (n.onset, n.is_drum, n.program, n.pitch, n.offset))
56
+
57
+
58
+ def sort_note_events(note_events: list[NoteEvent]):
59
+ if len(note_events) > 0:
60
+ note_events.sort(
61
+ key=lambda n: (n.time, n.is_drum, n.program, n.velocity, n.pitch)
62
+ )
63
+
64
+
65
+ def sort_tie_note_events(tie_note_events: list[TieNoteEvent]):
66
+ if len(tie_note_events) > 0:
67
+ tie_note_events.sort(key=lambda n: (n.program, n.pitch))
68
+
69
+
70
+ def validate_notes(
71
+ notes: list[Note],
72
+ minimum_offset: float | None = MINIMUM_NOTE_DURATION_SEC,
73
+ fix: bool = True,
74
+ ) -> list[Note]:
75
+ if len(notes) > 0:
76
+ for note in list(notes):
77
+ if note.onset is None and fix:
78
+ notes.remove(note)
79
+ elif note.offset is None and fix:
80
+ note.offset = note.onset + minimum_offset
81
+ elif note.onset > note.offset:
82
+ if fix:
83
+ note.offset = max(note.offset, note.onset + minimum_offset)
84
+ elif note.is_drum is False and note.offset - note.onset < 0.01 and fix:
85
+ note.offset = note.onset + minimum_offset
86
+ return notes
87
+
88
+
89
+ def trim_overlapping_notes(notes: list[Note], sort: bool = True) -> list[Note]:
90
+ if len(notes) <= 1:
91
+ return notes
92
+ trimmed_notes = []
93
+ channels = set((note.program, note.pitch, note.is_drum) for note in notes)
94
+ for program, pitch, is_drum in channels:
95
+ channel_notes = [
96
+ n
97
+ for n in notes
98
+ if n.pitch == pitch and n.program == program and n.is_drum == is_drum
99
+ ]
100
+ sorted_notes = sorted(channel_notes, key=lambda n: n.onset)
101
+ for i in range(1, len(sorted_notes)):
102
+ if sorted_notes[i - 1].offset > sorted_notes[i].onset:
103
+ sorted_notes[i - 1].offset = sorted_notes[i].onset
104
+ valid_notes = [n for n in sorted_notes if n.onset < n.offset]
105
+ trimmed_notes.extend(valid_notes)
106
+ if sort:
107
+ sort_notes(trimmed_notes)
108
+ return trimmed_notes
109
+
110
+
111
+ # Special tokens occupy the first indices of the vocabulary, in this order.
112
+ SPECIAL_TOKENS = ("PAD", "EOS", "UNK")
113
+
114
+
115
+ def build_event_vocab(max_shift_steps: int) -> list[Event]:
116
+ """Return the token-index → :class:`Event` decode table.
117
+
118
+ Index ``i`` maps to the event the model emits at token ``i``. The layout
119
+ is fixed: special tokens, then ``shift``, then the note-event ranges.
120
+ """
121
+ ranges = (
122
+ [EventRange(token, 0, 0) for token in SPECIAL_TOKENS]
123
+ + [EventRange("shift", 0, max_shift_steps - 1)]
124
+ + [
125
+ EventRange("pitch", 0, 127),
126
+ EventRange("velocity", 0, 1),
127
+ EventRange("tie", 0, 0),
128
+ EventRange("program", 0, 129),
129
+ EventRange("drum", 0, 127),
130
+ ]
131
+ )
132
+ vocab: list[Event] = []
133
+ for er in ranges:
134
+ for value in range(er.min_value, er.max_value + 1):
135
+ vocab.append(Event(type=er.type, value=value))
136
+ return vocab
137
+
138
+
139
+ def note_event2note(
140
+ note_events: list[NoteEvent],
141
+ tie_note_events: list[TieNoteEvent] | None = None,
142
+ shorten_notes_above_n_sec: int = 10,
143
+ fix_broken_notes: bool = True,
144
+ trim_overlap: bool = True,
145
+ force_offset_past_segment_end: float | None = None,
146
+ force_onset_before_segment_start: float | None = None,
147
+ ) -> tuple[list[Note], Counter]:
148
+ notes: list[Note] = []
149
+ active_note_events: dict[tuple[int, int], NoteEvent | TieNoteEvent] = {}
150
+ err_cnt: Counter = Counter()
151
+
152
+ if tie_note_events is not None:
153
+ for ne in tie_note_events:
154
+ active_note_events[(ne.program, ne.pitch)] = ne
155
+
156
+ sort_note_events(note_events)
157
+ for ne in note_events:
158
+ try:
159
+ if ne.time is None:
160
+ continue
161
+ elif ne.is_drum:
162
+ if ne.velocity == 1:
163
+ notes.append(
164
+ Note(
165
+ is_drum=True,
166
+ program=DRUM_PROGRAM,
167
+ onset=ne.time,
168
+ offset=ne.time + MINIMUM_NOTE_DURATION_SEC,
169
+ pitch=ne.pitch,
170
+ )
171
+ )
172
+ else:
173
+ continue
174
+ else:
175
+ active_ne = active_note_events.pop((ne.program, ne.pitch), None)
176
+ if ne.velocity == 0 and active_ne is None:
177
+ raise ValueError("Err/onset not found")
178
+ if active_ne is not None:
179
+ if type(active_ne) is NoteEvent:
180
+ notes.append(
181
+ Note(
182
+ is_drum=False,
183
+ program=active_ne.program,
184
+ onset=active_ne.time,
185
+ offset=ne.time,
186
+ pitch=active_ne.pitch,
187
+ )
188
+ )
189
+ else: # TieNoteEvent
190
+ notes.append(
191
+ Note(
192
+ is_drum=False,
193
+ program=active_ne.program,
194
+ onset=force_onset_before_segment_start,
195
+ offset=ne.time,
196
+ pitch=active_ne.pitch,
197
+ )
198
+ )
199
+ if ne.velocity == 1:
200
+ active_note_events[(ne.program, ne.pitch)] = ne
201
+ except ValueError as ve:
202
+ err_cnt[str(ve)] += 1
203
+
204
+ for ne in active_note_events.values():
205
+ try:
206
+ if type(ne) is NoteEvent and ne.velocity == 1:
207
+ if ne.program is None or ne.pitch is None:
208
+ raise ValueError("Err/active ne incomplete")
209
+ elif ne.time is None:
210
+ continue
211
+ else:
212
+ notes.append(
213
+ Note(
214
+ is_drum=False,
215
+ program=ne.program,
216
+ onset=ne.time,
217
+ offset=ne.time + MINIMUM_NOTE_DURATION_SEC
218
+ if force_offset_past_segment_end is None
219
+ else force_offset_past_segment_end,
220
+ pitch=ne.pitch,
221
+ )
222
+ )
223
+ except ValueError as ve:
224
+ err_cnt[str(ve)] += 1
225
+
226
+ if shorten_notes_above_n_sec > 0:
227
+ for n in list(notes):
228
+ try:
229
+ if n.offset - n.onset > shorten_notes_above_n_sec:
230
+ n.offset = n.onset + MINIMUM_NOTE_DURATION_SEC
231
+ raise ValueError(f"Err/long note > {shorten_notes_above_n_sec}s")
232
+ except ValueError as ve:
233
+ err_cnt[str(ve)] += 1
234
+ if fix_broken_notes:
235
+ notes = validate_notes(notes, fix=True)
236
+ if trim_overlap:
237
+ notes = trim_overlapping_notes(notes, sort=True)
238
+ else:
239
+ sort_notes(notes)
240
+ return notes, err_cnt
241
+
242
+
243
+ def note2note_event(notes: list[Note]) -> list[NoteEvent]:
244
+ note_events = []
245
+ for note in notes:
246
+ if note.program == 1024:
247
+ note.is_drum = True
248
+ note_events.append(
249
+ NoteEvent(note.is_drum, note.program, note.onset, 1, note.pitch)
250
+ )
251
+ if not note.is_drum:
252
+ note_events.append(
253
+ NoteEvent(note.is_drum, note.program, note.offset, 0, note.pitch)
254
+ )
255
+ sort_note_events(note_events)
256
+ return note_events
257
+
258
+
259
+ def note_event2midi(
260
+ note_events: list[NoteEvent],
261
+ output_file: str | os.PathLike | None = None,
262
+ velocity: int = 100,
263
+ ticks_per_beat: int = 480,
264
+ tempo: int = 500000,
265
+ ) -> MidiFile:
266
+ """Convert NoteEvent list to a MIDI file."""
267
+ midi = MidiFile(ticks_per_beat=ticks_per_beat, type=0)
268
+ track = MidiTrack()
269
+ midi.tracks.append(track)
270
+
271
+ drum_offset_events = []
272
+ for ne in note_events:
273
+ if ne.is_drum:
274
+ drum_offset_events.append(
275
+ NoteEvent(
276
+ is_drum=True,
277
+ program=ne.program,
278
+ time=ne.time + 0.01,
279
+ pitch=ne.pitch,
280
+ velocity=0,
281
+ )
282
+ )
283
+ note_events = list(note_events) + drum_offset_events
284
+ sort_note_events(note_events)
285
+
286
+ program_to_channel: dict[int, int] = {}
287
+ available_channels = list(range(0, 9)) + list(range(10, 16))
288
+ drums_initialized = False
289
+ current_tick = 0
290
+ for ne in note_events:
291
+ absolute_tick = round(second2tick(ne.time, ticks_per_beat, tempo))
292
+ if absolute_tick == current_tick:
293
+ delta_tick = 0
294
+ elif absolute_tick < current_tick:
295
+ raise ValueError(
296
+ f"at ne.time {ne.time}, absolute_tick {absolute_tick} < current_tick {current_tick}"
297
+ )
298
+ else:
299
+ delta_tick = absolute_tick - current_tick
300
+ current_tick += delta_tick
301
+
302
+ if ne.program in program_to_channel:
303
+ ne_channel = program_to_channel[ne.program]
304
+ elif ne.program == DRUM_PROGRAM or ne.is_drum:
305
+ ne_channel = 9
306
+ if not drums_initialized:
307
+ track.append(
308
+ Message(
309
+ "program_change", program=0, time=delta_tick, channel=ne_channel
310
+ )
311
+ )
312
+ delta_tick = 0
313
+ drums_initialized = True
314
+ else:
315
+ try:
316
+ program_to_channel[ne.program] = available_channels.pop(0)
317
+ except IndexError:
318
+ program_to_channel[ne.program] = 15
319
+ ne_channel = program_to_channel[ne.program]
320
+ track.append(
321
+ Message(
322
+ "program_change",
323
+ program=ne.program,
324
+ time=delta_tick,
325
+ channel=ne_channel,
326
+ )
327
+ )
328
+ delta_tick = 0
329
+
330
+ msg_note = "note_on" if ne.velocity > 0 else "note_off"
331
+ msg_velocity = velocity if ne.velocity > 0 else 0
332
+ track.append(
333
+ Message(
334
+ msg_note,
335
+ note=ne.pitch,
336
+ velocity=msg_velocity,
337
+ time=delta_tick,
338
+ channel=ne_channel,
339
+ )
340
+ )
341
+
342
+ if output_file is not None:
343
+ midi.save(output_file)
344
+ return midi