Skip to content

Commit da611d8

Browse files
committed
Fix up MIDI export and import (within reason)
This clarifies the logic around tempo and time signature in both exports and imports. The tempo/bpm conversion seems to be correct and reliable. The time signature is not so lucky. First of all, Bosca has a fixed time signature, the default 4/4. There is no way to change that, so we pretty much hardcode it (although now it's a bit clearer what we hardcode, MIDI spec is weird). On import the old code used to attempt to rebuild the pattern size using the time signature. But that's not exactly a robust approach, since, as I mentioned, even in Bosca it's always 4/4, whereas the pattern size can vary. The best we can do is to just use some fixed unit of size and hope it's going to generate nice patterns on import. And for this unit we can use a part of the signature that makes sense for this role, the numerator. It could be nice to add an extra step to the import and let users decide how large the pattern should be. That said, there is no working around other MIDI-specific quirks, like each track having its own time signature — a feature that would be impossible to preserve in Bosca. Aside of all that, this also fixes a couple of issues related to the order of events (both on import and on export), and a problem with invalid notes slipping into MIDI files anyway.
1 parent fa40539 commit da611d8

File tree

10 files changed

+107
-30
lines changed

10 files changed

+107
-30
lines changed

gui/views/note_map/NoteMap.gd

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -529,8 +529,8 @@ func _update_active_notes() -> void:
529529

530530
for i in current_pattern.note_amount:
531531
var note_data := current_pattern.notes[i]
532-
if note_data.y < 0 || note_data.y >= pattern_size || note_data.z < 1:
533-
continue # Outside of the pattern row, or too short to play.
532+
if not current_pattern.is_note_valid(note_data, pattern_size):
533+
continue # Outside of the pattern bounds, or too short to play.
534534

535535
var note_value_normalized := note_data.x - current_pattern.key # Shift to its C-key equivalent.
536536
if note_value_normalized < 0 || note_value_normalized >= _note_value_row_map.size():

gui/views/pattern_map/PatternDock.gd

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,20 +86,20 @@ func _draw_item(on_control: Control, item_index: int, item_rect: Rect2) -> void:
8686
Vector2(_item_gutter_width - _note_border_width, 2.0 * item_rect.size.y / 5.0)
8787
)
8888
on_control.draw_rect(label_underline, item_color)
89-
89+
9090
# Draw gutter string.
9191

9292
var string_position := item_rect.position + _item_label_offset + Vector2(0, item_rect.size.y)
9393
var shadow_position := string_position + _shadow_size
9494
var gutter_string := "%d" % [ item_index + 1 ]
9595
on_control.draw_string(_font, shadow_position, gutter_string, HORIZONTAL_ALIGNMENT_LEFT, -1, _font_size, _shadow_color)
9696
on_control.draw_string(_font, string_position, gutter_string, HORIZONTAL_ALIGNMENT_LEFT, -1, _font_size, _font_color)
97-
97+
9898
# Draw pattern note map.
9999

100100
if pattern.note_amount > 0:
101101
var pattern_size := Controller.current_song.pattern_size
102-
102+
103103
var note_span := pattern.get_active_note_span_size()
104104
var note_width := note_area.size.x / pattern_size
105105
var note_height := note_area.size.y / note_span
@@ -117,9 +117,9 @@ func _draw_item(on_control: Control, item_index: int, item_rect: Rect2) -> void:
117117

118118
for i in pattern.note_amount:
119119
var note := pattern.notes[i]
120-
if note.x < 0 || note.y < 0 || note.y >= pattern_size || note.z < 1:
120+
if not pattern.is_note_valid(note, pattern_size):
121121
continue
122-
122+
123123
var note_index := note.x - note_value_offset
124124
var note_position := note_origin + Vector2(note_width * note.y, note_span_height - note_height * (note_index + 1))
125125
var note_size := Vector2(note_width * note.z, note_height)

gui/views/pattern_map/PatternMap.gd

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -489,9 +489,9 @@ func _update_active_patterns() -> void:
489489
var note_value_offset := pattern.active_note_span[0]
490490
for j in pattern.note_amount:
491491
var note := pattern.notes[j]
492-
if note.x < 0 || note.y < 0 || note.y >= pattern_size || note.z < 1:
492+
if not pattern.is_note_valid(note, pattern_size):
493493
continue
494-
494+
495495
var note_index := note.x - note_value_offset
496496
var note_position := note_origin + Vector2(note_width * note.y, note_span_height - note_height * (note_index + 1))
497497
var note_size := Vector2(note_width * note.z, note_height)

io/MidiExporter.gd

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ static func _write(writer: MidiFileWriter, song: Song) -> void:
4545
# Track chunk 1. Meta events.
4646

4747
var meta_track := writer.create_track()
48-
meta_track.add_time_signature(4, 2, 24, 8)
49-
meta_track.add_tempo(song.bpm)
48+
meta_track.add_time_signature(4, 4) # Bosca always operates with the 4/4 signature.
49+
meta_track.add_tempo(song.bpm, 4)
5050

5151
# Track chunk 2. Non-drumkit instruments and notes.
5252
# Channel 9 is special and is used for drums, so we avoid it by splitting the list in two.
@@ -95,6 +95,8 @@ static func _write_instruments_to_track(track: MidiTrack, song: Song, limit: int
9595

9696
for k in pattern.note_amount:
9797
var note := pattern.notes[k]
98+
if not pattern.is_note_valid(note, song.pattern_size):
99+
continue
98100

99101
track.add_note(pattern.instrument_idx - offset, note_offset + note.y, note.x, note.z, instrument.volume)
100102

@@ -119,8 +121,10 @@ static func _write_drumkits_to_track(track: MidiTrack, song: Song) -> void:
119121

120122
for k in pattern.note_amount:
121123
var note := pattern.notes[k]
122-
var note_value := drumkit_instrument.get_midi_note(note.x)
124+
if not pattern.is_note_valid(note, song.pattern_size):
125+
continue
123126

127+
var note_value := drumkit_instrument.get_midi_note(note.x)
124128
track.add_note(MidiFile.DRUMKIT_CHANNEL, note_offset + note.y, note_value, note.z, instrument.volume)
125129

126130

io/MidiImporter.gd

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,12 @@ class MidiFileReader:
117117
var signature_found := false
118118
var tempo_found := false
119119

120+
# Default values per MIDI spec.
121+
var signature_numerator := 4
122+
var signature_denominator := 4
123+
var tempo := 500_000
124+
125+
# Look for the settings in any track. Usually it's the first one, but we can't be sure.
120126
for track in _tracks:
121127
var events := track.get_events()
122128

@@ -126,26 +132,56 @@ class MidiFileReader:
126132
var meta_payload := event.payload as MidiTrackEvent.MetaPayload
127133

128134
if not signature_found && meta_payload.meta_type == MidiTrackEvent.MetaType.TIME_SIGNATURE:
129-
var numerator := meta_payload.data[0]
130-
var denominator := 1 << meta_payload.data[1]
135+
signature_numerator = meta_payload.data[0]
136+
signature_denominator = 1 << meta_payload.data[1]
131137

132-
# This is a bit dubious, but matches the original implementation.
133-
song.pattern_size = clampi(numerator * denominator, 0, 32)
138+
# Bosca only supports one time signature per song, which isn't necessarily
139+
# true for MIDI files. Can't do much about it, so using the first one and praying.
140+
print_verbose("MidiImporter: Found a time signature message (%d, %d)." % [ signature_numerator, signature_denominator ])
134141
signature_found = true
135142

136143
if not tempo_found && meta_payload.meta_type == MidiTrackEvent.MetaType.TEMPO_SETTING:
137144
# Tempo is stored in 24 bits.
138-
var tempo := 0
145+
tempo = 0
139146
tempo += meta_payload.data[0] << 16
140147
tempo += meta_payload.data[1] << 8
141148
tempo += meta_payload.data[2]
142149

143-
@warning_ignore("integer_division")
144-
song.bpm = 60_000_000 / tempo
150+
print_verbose("MidiImporter: Found a tempo message (%d)." % [ tempo ])
145151
tempo_found = true
146152

147153
if signature_found && tempo_found:
148-
return
154+
break
155+
if signature_found && tempo_found:
156+
break
157+
158+
# Compute pattern size and BPM based on our findings.
159+
160+
# The original implementation tries to use the numerator and denominator
161+
# from the time signature to guess a pattern size. This is obviously not
162+
# a reliable method, and we cannot even recreate an original Bosca song
163+
# exported as MIDI this way.
164+
#
165+
# This is because the time signature defines the nature of a beat (how
166+
# many quarter* notes it has). For example, in Bosca Ceoil the time
167+
# signature is ALWAYS 4/4, regardless of the pattern size or other settings.
168+
#
169+
# To determine the pattern size properly we'd have to inspect the notes.
170+
# And, in the end, we have no guarantee that notes would neatly pack into
171+
# patterns anyway. It'd be a complex task to try and build patterns out of
172+
# random MIDI notes.
173+
#
174+
# So the best we can do is to use an arbitrary pattern size (which was,
175+
# perhaps, what the original implementation did, in a way). We can use
176+
# the numerator of the time signature alone, to try and turn a MIDI song
177+
# into a bunch of beat-long patterns.
178+
179+
# TODO: Perhaps expose a factor to this as an import setting somehow?
180+
var beats_per_pattern := 1
181+
song.pattern_size = clampi(signature_numerator * beats_per_pattern, 1, Song.MAX_PATTERN_SIZE)
182+
183+
@warning_ignore("integer_division")
184+
song.bpm = roundi(MidiFile.TEMPO_BASE / tempo * signature_denominator / 4.0)
149185

150186

151187
func extract_composition(song: Song) -> void:
@@ -230,14 +266,32 @@ class MidiFileReader:
230266
var midi_track := _tracks[track_idx]
231267
var note_pitch := midi_payload.data[0]
232268

269+
# We first try to find a note that has been around for a bit and turn it off.
270+
# So we're skipping all notes which aren't valid. But in case we don't find
271+
# any, we should turn off the first "not-so-valid" note at least. Hence,
272+
# candidates.
273+
var candidates: Array[MidiNote] = []
274+
var check_candidates := true
275+
233276
var i := instrument_notes.size() - 1
234277
while i >= 0:
235278
var midi_note := instrument_notes[i]
236279
if midi_note.pitch == note_pitch && midi_note.length < 0:
280+
candidates.push_back(midi_note)
281+
237282
@warning_ignore("integer_division")
238-
midi_note.length = (timestamp - midi_note.timestamp) / midi_track.note_time
283+
var new_length := (timestamp - midi_note.timestamp) / midi_track.note_time
284+
if new_length > 0:
285+
midi_note.length = new_length
286+
check_candidates = false
287+
break
239288

240289
i -= 1
290+
291+
if check_candidates:
292+
var midi_note: MidiNote = candidates.pop_front()
293+
@warning_ignore("integer_division")
294+
midi_note.length = (timestamp - midi_note.timestamp) / midi_track.note_time
241295

242296

243297
func create_patterns(song: Song) -> void:

io/SongMerger.gd

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,7 @@ static func encode_pattern(pattern: Pattern, instrument: Instrument, pattern_siz
3636
# Prepare note data.
3737
for i in pattern.note_amount:
3838
var note_data := pattern.notes[i]
39-
if note_data.x < 0 || note_data.y < 0 || note_data.y >= pattern_size || note_data.z < 1:
40-
# X — note number is invalid.
41-
# Y — note position in the pattern is invalid.
42-
# Z — note length is shorter than 1 unit of length.
39+
if not pattern.is_note_valid(note_data, pattern_size):
4340
continue
4441

4542
# Encode the note itself.

io/midi/MidiFile.gd

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,7 @@ enum FileFormat {
1616
const DEFAULT_RESOLUTION := 120
1717
const DRUMKIT_CHANNEL := 9
1818

19+
const TEMPO_BASE := 60_000_000
20+
1921
const FILE_HEADER_MARKER := "MThd"
2022
const FILE_TRACK_MARKER := "MTrk"

io/midi/MidiTrack.gd

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ func get_events() -> Array[MidiTrackEvent]:
6565

6666

6767
func _sort_events(a: MidiTrackEvent, b: MidiTrackEvent) -> bool:
68+
if a.timestamp == b.timestamp:
69+
return a.order < b.order # Preserve insertion order when timestamp is the same.
70+
6871
return a.timestamp < b.timestamp
6972

7073

@@ -74,6 +77,7 @@ func add_meta_event(meta_type: MidiTrackEvent.MetaType, timestamp: int, data: Pa
7477
var event := MidiTrackEvent.new()
7578
event.type = MidiTrackEvent.Type.META_EVENT
7679
event.timestamp = timestamp
80+
event.order = _events.size()
7781

7882
var payload := MidiTrackEvent.MetaPayload.new()
7983
payload.meta_type = meta_type
@@ -84,22 +88,30 @@ func add_meta_event(meta_type: MidiTrackEvent.MetaType, timestamp: int, data: Pa
8488
_events_unsorted = true
8589

8690

87-
func add_time_signature(numerator: int, denominator: int, clocks_per_click: int, notated_notes: int) -> void:
91+
func add_time_signature(numerator: int, denominator: int, clocks_per_click: int = 24, quarter_note_resolution: int = 8) -> void:
8892
var event_data := PackedByteArray()
93+
94+
# Denominator is stored as a power-of-2 value (e.g. 4 -> 2, 8 -> 3, etc).
95+
var po2_denominator := 0
96+
var n := denominator
97+
while n > 1:
98+
n >>= 1
99+
po2_denominator += 1
100+
89101
event_data.append(numerator)
90-
event_data.append(denominator)
102+
event_data.append(po2_denominator)
91103
event_data.append(clocks_per_click)
92-
event_data.append(notated_notes)
104+
event_data.append(quarter_note_resolution)
93105

94106
add_meta_event(MidiTrackEvent.MetaType.TIME_SIGNATURE, 0, event_data)
95107

96108

97-
func add_tempo(bpm: int) -> void:
109+
func add_tempo(bpm: int, denominator: int) -> void:
98110
var event_data := PackedByteArray()
99111

100112
# Tempo is stored in microseconds per MIDI quarter-note.
101113
@warning_ignore("integer_division")
102-
var misec_per_beat := 60_000_000 / bpm
114+
var misec_per_beat := int(MidiFile.TEMPO_BASE / bpm * denominator / 4.0)
103115

104116
# Value is always stored in 24 bits.
105117
event_data.append((misec_per_beat >> 16) & 0xFF)
@@ -115,6 +127,7 @@ func add_midi_event(midi_type: MidiTrackEvent.MidiType, channel_num: int, timest
115127
var event := MidiTrackEvent.new()
116128
event.type = MidiTrackEvent.Type.MIDI_EVENT
117129
event.timestamp = timestamp
130+
event.order = _events.size()
118131

119132
var payload := MidiTrackEvent.MidiPayload.new()
120133
payload.midi_type = midi_type

io/midi/MidiTrackEvent.gd

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ enum MidiType {
5050

5151
var type: Type = Type.META_EVENT
5252
var timestamp: int = 0
53+
var order: int = 0
5354
var payload: Payload = null
5455

5556

objects/Pattern.gd

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,3 +389,9 @@ func remove_note_at(index: int) -> void:
389389
notes[i] = Vector3i(-1, 0, 0)
390390

391391
note_amount -= 1
392+
393+
394+
func is_note_valid(note: Vector3i, pattern_size: int) -> bool:
395+
if note.x < 0 || note.y < 0 || note.y >= pattern_size || note.z < 1:
396+
return false
397+
return true

0 commit comments

Comments
 (0)