forked from YuriSizov/boscaceoil-blue
-
Notifications
You must be signed in to change notification settings - Fork 0
/
MidiImporter.gd
346 lines (252 loc) · 11.6 KB
/
MidiImporter.gd
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
###################################################
# Part of Bosca Ceoil Blue #
# Copyright (c) 2024 Yuri Sizov and contributors #
# Provided under MIT #
###################################################
# MIDI format implementation is based on the specification description referenced from:
# - https://www.music.mcgill.ca/~ich/classes/mumt306/StandardMIDIfileformat.html
# - https://ccrma.stanford.edu/~craig/14q/midifile/MidiFileFormat.html
class_name MidiImporter extends RefCounted
const FILE_EXTENSION := "mid"
static func import(path: String) -> Song:
if path.get_extension() != FILE_EXTENSION:
printerr("MidiImporter: The MIDI file must have a .%s extension." % [ FILE_EXTENSION ])
return null
var file := FileAccess.open(path, FileAccess.READ)
var error := FileAccess.get_open_error()
if error != OK:
printerr("MidiImporter: Failed to open the file at '%s' for reading (code %d)." % [ path, error ])
return null
file.big_endian = true
var reader := MidiFileReader.new(file)
return _read(reader)
static func _read(reader: MidiFileReader) -> Song:
var song := Song.new()
if not reader.read_midi_header():
return null
if not reader.read_midi_tracks():
return null
reader.extract_song_settings(song)
reader.extract_composition(song)
reader.create_patterns(song)
song.mark_dirty() # Imported song is not saved.
return song
class MidiFileReader:
var format: int = MidiFile.FileFormat.MULTI_TRACK
var resolution: int = MidiFile.DEFAULT_RESOLUTION
var _tracks: Array[MidiTrack] = []
var _instruments_index_map: Dictionary = {}
var _notes_instrument_map: Dictionary = {}
var _file: FileAccess = null
func _init(file: FileAccess) -> void:
_file = file
_file.seek(0)
func read_midi_header() -> bool:
var marker := ByteArrayUtil.read_string(_file, 4)
if marker != MidiFile.FILE_HEADER_MARKER:
printerr("MidiImporter: Failed to read the file at '%s', header marker is missing." % [ _file.get_path() ])
return false
# Read header bytes in order.
_file.get_32() # Header size.
format = _file.get_16()
var track_num := _file.get_16()
resolution = _file.get_16()
for i in track_num:
_tracks.push_back(MidiTrack.new())
return true
func read_midi_tracks() -> bool:
var i := 0
for track in _tracks:
var marker := ByteArrayUtil.read_string(_file, 4)
if marker != MidiFile.FILE_TRACK_MARKER:
printerr("MidiImporter: Failed to read the file at '%s', track marker is missing in track #%d." % [ _file.get_path(), i ])
return false
var track_size := _file.get_32()
var track_end_position := _file.get_position() + track_size
if track_end_position > _file.get_length():
printerr("MidiImporter: Failed to read the file at '%s', track length extends beyond file length in track #%d." % [ _file.get_path(), i ])
return false
var event_timestamp := 0
while _file.get_position() < track_end_position:
var event_delta := ByteArrayUtil.read_vlen(_file)
var event_type := _file.get_8()
event_timestamp += event_delta
if not track.parse_event(event_type, event_timestamp, _file):
printerr("MidiImporter: Failed to read the file at '%s', track event (0x%02X) at %d is malformed or unknown in track #%d." % [ _file.get_path(), event_type, event_timestamp, i ])
return false
i += 1
if _file.get_position() < _file.get_length():
printerr("MidiImporter: The file at '%s' contains excessive data, it's either malformed or contains unsupported events." % [ _file.get_path() ])
return false
return true
func extract_song_settings(song: Song) -> void:
var signature_found := false
var tempo_found := false
for track in _tracks:
var events := track.get_events()
for event in events:
if not event.payload || event.payload is not MidiTrackEvent.MetaPayload:
continue
var meta_payload := event.payload as MidiTrackEvent.MetaPayload
if not signature_found && meta_payload.meta_type == MidiTrackEvent.MetaType.TIME_SIGNATURE:
var numerator := meta_payload.data[0]
var denominator := 1 << meta_payload.data[1]
# This is a bit dubious, but matches the original implementation.
song.pattern_size = clampi(numerator * denominator, 0, 32)
signature_found = true
if not tempo_found && meta_payload.meta_type == MidiTrackEvent.MetaType.TEMPO_SETTING:
# Tempo is stored in 24 bits.
var tempo := 0
tempo += meta_payload.data[0] << 16
tempo += meta_payload.data[1] << 8
tempo += meta_payload.data[2]
@warning_ignore("integer_division")
song.bpm = 60_000_000 / tempo
tempo_found = true
if signature_found && tempo_found:
return
func extract_composition(song: Song) -> void:
var i := 0
for track in _tracks:
var events := track.get_events()
for event in events:
if not event.payload || event.payload is not MidiTrackEvent.MidiPayload:
continue
var midi_payload := event.payload as MidiTrackEvent.MidiPayload
if midi_payload.midi_type == MidiTrackEvent.MidiType.PROGRAM_CHANGE:
_extract_instrument(i, midi_payload, song)
if midi_payload.midi_type == MidiTrackEvent.MidiType.NOTE_ON:
_extract_note(i, midi_payload, event.timestamp)
if midi_payload.midi_type == MidiTrackEvent.MidiType.NOTE_OFF:
_change_note_length(i, midi_payload, event.timestamp)
i += 1
func _extract_instrument(track_idx: int, midi_payload: MidiTrackEvent.MidiPayload, song: Song) -> void:
var midi_instrument := MidiInstrument.new()
midi_instrument.track_index = track_idx
midi_instrument.channel_num = midi_payload.channel_num
midi_instrument.midi_voice = midi_payload.data[0]
# Keep a map for future use by notes.
var index_key := midi_instrument.get_index_key()
_instruments_index_map[index_key] = song.instruments.size()
var instrument_notes: Array[MidiNote] = []
_notes_instrument_map[index_key] = instrument_notes
# Create the instrument itself and add it to the song.
if midi_instrument.channel_num == MidiFile.DRUMKIT_CHANNEL: # This is drums, map to a drumkit.
var voice_data := Controller.voice_manager.get_voice_data("DRUMKIT", "MIDI Drumkit")
var bosca_drums := DrumkitInstrument.new(voice_data)
bosca_drums.volume = 0 # Will be set when iterating notes.
song.instruments.push_back(bosca_drums)
else:
var voice_data := Controller.voice_manager.get_voice_data_for_midi(midi_instrument.midi_voice)
if not voice_data:
# If there is no match, you can't go wrong with a piano. But there should always be a match.
voice_data = Controller.voice_manager.get_voice_data("MIDI", "Grand Piano")
var bosca_instrument := SingleVoiceInstrument.new(voice_data)
bosca_instrument.volume = 0 # Will be set when iterating notes.
song.instruments.push_back(bosca_instrument)
func _extract_note(track_idx: int, midi_payload: MidiTrackEvent.MidiPayload, timestamp: int) -> void:
var midi_volume := midi_payload.data[1]
# When a note on event comes with zero velocity/volume, it's actually a note off event.
if midi_volume == 0:
_change_note_length(track_idx, midi_payload, timestamp)
return
var midi_track := _tracks[track_idx]
var midi_note := MidiNote.new()
midi_note.track_index = track_idx
midi_note.channel_num = midi_payload.channel_num
midi_note.timestamp = timestamp
# Set pitch and volume, but keep length at -1 until we find the note off event.
midi_note.pitch = midi_payload.data[0]
midi_note.volume = midi_volume * 2
@warning_ignore("integer_division")
midi_note.position = timestamp / midi_track.note_time
var instrument_key := midi_note.get_instrument_key()
var instrument_notes: Array[MidiNote] = _notes_instrument_map[instrument_key]
instrument_notes.push_back(midi_note)
func _change_note_length(track_idx: int, midi_payload: MidiTrackEvent.MidiPayload, timestamp: int) -> void:
var instrument_key := Vector2i(track_idx, midi_payload.channel_num)
var instrument_notes: Array[MidiNote] = _notes_instrument_map[instrument_key]
var midi_track := _tracks[track_idx]
var note_pitch := midi_payload.data[0]
var i := instrument_notes.size() - 1
while i >= 0:
var midi_note := instrument_notes[i]
if midi_note.pitch == note_pitch && midi_note.length < 0:
@warning_ignore("integer_division")
midi_note.length = (timestamp - midi_note.timestamp) / midi_track.note_time
i -= 1
func create_patterns(song: Song) -> void:
for instrument_key: Vector2i in _notes_instrument_map:
var instrument_index: int = _instruments_index_map[instrument_key]
var instrument := song.instruments[instrument_index]
var instrument_notes: Array[MidiNote] = _notes_instrument_map[instrument_key]
var bar_idx := 0
var current_time := 0
var bar_end_time := song.pattern_size
var pattern := Pattern.new()
pattern.instrument_idx = _instruments_index_map[instrument_key]
var note_idx := 0
while note_idx < instrument_notes.size():
var next_note := instrument_notes[note_idx]
current_time = next_note.position
if current_time >= bar_end_time:
# The next note is outside of the current bar.
# Commit the pattern, move cursors and start a new one.
_commit_pattern_to_arrangement(pattern, song, bar_idx)
@warning_ignore("integer_division")
bar_idx = current_time / song.pattern_size
bar_end_time = (bar_idx + 1) * song.pattern_size
pattern = Pattern.new()
pattern.instrument_idx = _instruments_index_map[instrument_key]
var note_value := next_note.pitch
if next_note.channel_num == MidiFile.DRUMKIT_CHANNEL:
var drumkit_instrument := instrument as DrumkitInstrument
note_value = drumkit_instrument.get_note_from_midi_note(next_note.pitch)
if next_note.volume > instrument.volume:
instrument.volume = next_note.volume
pattern.add_note(note_value, next_note.position % song.pattern_size, next_note.length, false)
note_idx += 1
_commit_pattern_to_arrangement(pattern, song, bar_idx)
song.arrangement.set_loop(0, bar_idx + 1)
func _commit_pattern_to_arrangement(pattern: Pattern, song: Song, bar_idx: int) -> void:
var pattern_index := song.patterns.size()
var reusing_pattern := false
# First, check if this is a duplicate of another pattern. Use the previous one if it is.
var i := 0
for other_pattern in song.patterns:
if other_pattern.instrument_idx == pattern.instrument_idx && other_pattern.get_hash() == pattern.get_hash():
pattern_index = i
reusing_pattern = true
break
i += 1
# If it's not a duplicate, finalize the pattern, and add it to the song.
if not reusing_pattern:
pattern.sort_notes()
pattern.reindex_active_notes()
song.patterns.push_back(pattern)
# In either case, try to write the pattern to the arrangement.
var pattern_added := false
for j in Arrangement.CHANNEL_NUMBER:
if song.arrangement.has_pattern(bar_idx, j):
continue
song.arrangement.set_pattern(bar_idx, j, pattern_index)
pattern_added = true
break
if not pattern_added:
printerr("MidiImporter: Couldn't find a free channel for pattern %d at bar %d." % [ pattern_index, bar_idx ])
class MidiInstrument:
var track_index: int = -1
var channel_num: int = -1
var midi_voice: int = -1
func get_index_key() -> Vector2i:
return Vector2i(track_index, channel_num)
class MidiNote:
var track_index: int = -1
var channel_num: int = -1
var timestamp: int = -1
var pitch: int = -1
var volume: int = -1
var position: int = -1
var length: int = -1
func get_instrument_key() -> Vector2i:
return Vector2i(track_index, channel_num)