Skip to content

Commit 74dba2e

Browse files
committed
Improved MIDI converter accuracy
1 parent 5d75c71 commit 74dba2e

4 files changed

Lines changed: 174 additions & 77 deletions

File tree

src/main/java/net/raphimc/noteblocklib/format/midi/MidiDefinitions.java

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,26 @@
1919

2020
public class MidiDefinitions {
2121

22+
// MetaMessage types
23+
public static final int META_TEXT = 0x01;
2224
public static final int META_COPYRIGHT_NOTICE = 0x02;
2325
public static final int META_TRACK_NAME = 0x03;
2426
public static final int META_SET_TEMPO = 0x51;
2527

28+
// CONTROL_CHANGE commands
29+
public static final int CONTROL_CHANNEL_VOLUME_MSB = 0x07;
30+
public static final int CONTROL_PAN_MSB = 0x0A;
31+
public static final int CONTROL_EXPRESSION_CONTROLLER_MSB = 0x0B;
32+
public static final int CONTROL_RESET_ALL_CONTROLLERS = 0x79;
33+
34+
// SysexMessage
35+
public static final int SYSEX_UNIVERSAL_NON_REALTIME_MESSAGE = 0x7E;
36+
public static final int SYSEX_DEVICE_ALL = 0x7F;
37+
public static final int SYSEX_SUB_ID_GENERAL_MIDI = 0x09;
38+
public static final int SYSEX_GENERAL_MIDI_GM1_SYSTEM_ON = 0x01;
39+
public static final int SYSEX_GENERAL_MIDI_GM2_SYSTEM_ON = 0x03;
40+
2641
public static final int PERCUSSION_CHANNEL = 9;
27-
public static final int VOLUME_CONTROL_MSB = 0x07;
28-
public static final int PAN_CONTROL_MSB = 0x0A;
29-
public static final int RESET_CONTROLS = 0x79;
3042

3143
public static final int LOWEST_KEY = 0;
3244
public static final int HIGHEST_KEY = 127;
@@ -36,6 +48,7 @@ public class MidiDefinitions {
3648

3749
public static final int CHANNEL_COUNT = 16;
3850
public static final int DEFAULT_TEMPO_MPQ = 500_000;
51+
public static final byte DEFAULT_VOLUME = 100;
3952
public static final byte MAX_VELOCITY = 127;
4053
public static final byte CENTER_PAN = 64;
4154

src/main/java/net/raphimc/noteblocklib/format/midi/MidiIo.java

Lines changed: 131 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
import java.io.InputStream;
3232
import java.nio.charset.StandardCharsets;
3333
import java.util.Arrays;
34+
import java.util.stream.Collectors;
35+
import java.util.stream.IntStream;
3436

3537
import static javax.sound.midi.ShortMessage.*;
3638
import static net.raphimc.noteblocklib.format.midi.MidiDefinitions.*;
@@ -49,24 +51,25 @@ public static MidiSong parseSong(final Sequence sequence, final String fileName)
4951
return parseSong(sequence, fileName, true);
5052
}
5153

52-
public static MidiSong parseSong(final Sequence sequence, final String fileName, final boolean skipOutOfRangeNotes) {
53-
final MidiSong song = new MidiSong(fileName);
54-
54+
public static MidiSong parseSong(final Sequence sequence, final String fileName, final boolean skipOutOfNbsRangeNotes) {
5555
if (sequence.getTickLength() > Integer.MAX_VALUE) {
5656
throw new IllegalArgumentException("MIDI sequence has too many ticks");
5757
}
5858

59+
final MidiSong song = new MidiSong(fileName);
5960
if (sequence.getDivisionType() == Sequence.PPQ) {
60-
song.getTempoEvents().set(0, (float) (1_000_000D / ((double) MidiDefinitions.DEFAULT_TEMPO_MPQ / sequence.getResolution())));
61+
song.getTempoEvents().set(0, (float) (1_000_000D / ((double) DEFAULT_TEMPO_MPQ / sequence.getResolution())));
6162
} else {
6263
song.getTempoEvents().set(0, sequence.getResolution() * sequence.getDivisionType());
6364
}
6465

65-
final byte[] channelInstruments = new byte[MidiDefinitions.CHANNEL_COUNT];
66-
final byte[] channelVolumes = new byte[MidiDefinitions.CHANNEL_COUNT];
67-
final byte[] channelPans = new byte[MidiDefinitions.CHANNEL_COUNT];
68-
Arrays.fill(channelVolumes, MidiDefinitions.MAX_VELOCITY);
69-
Arrays.fill(channelPans, MidiDefinitions.CENTER_PAN);
66+
final byte[] channelInstruments = new byte[CHANNEL_COUNT];
67+
final byte[] channelVolumes = new byte[CHANNEL_COUNT];
68+
final byte[] channelPans = new byte[CHANNEL_COUNT];
69+
final byte[] channelExpressions = new byte[CHANNEL_COUNT];
70+
Arrays.fill(channelVolumes, DEFAULT_VOLUME);
71+
Arrays.fill(channelPans, CENTER_PAN);
72+
Arrays.fill(channelExpressions, Byte.MAX_VALUE);
7073

7174
for (int trackIdx = 0; trackIdx < sequence.getTracks().length; trackIdx++) {
7275
final Track track = sequence.getTracks()[trackIdx];
@@ -78,82 +81,169 @@ public static MidiSong parseSong(final Sequence sequence, final String fileName,
7881
final ShortMessage shortMessage = (ShortMessage) message;
7982
switch (shortMessage.getCommand()) {
8083
case NOTE_ON:
84+
final byte key = (byte) MathUtil.clamp(shortMessage.getData1(), LOWEST_KEY, HIGHEST_KEY);
85+
final byte velocity = (byte) MathUtil.clamp(shortMessage.getData2(), 0, MAX_VELOCITY);
8186
final byte instrument = channelInstruments[shortMessage.getChannel()];
82-
final byte key = (byte) shortMessage.getData1();
83-
final byte velocity = (byte) shortMessage.getData2();
87+
final byte volume = channelVolumes[shortMessage.getChannel()];
8488
final byte pan = channelPans[shortMessage.getChannel()];
89+
final byte expression = channelExpressions[shortMessage.getChannel()];
8590

8691
final Note note = new Note();
8792
if (shortMessage.getChannel() == PERCUSSION_CHANNEL) {
8893
final PercussionMapping mapping = MidiMappings.PERCUSSION_MAPPINGS[key];
89-
if (mapping == null) continue;
94+
if (mapping == null) {
95+
continue;
96+
}
9097

9198
note.setInstrument(mapping.getInstrument());
9299
note.setNbsKey(mapping.getNbsKey());
93100
} else {
94101
final InstrumentMapping mapping = MidiMappings.INSTRUMENT_MAPPINGS[instrument];
95-
if (mapping == null) continue;
102+
if (mapping == null) {
103+
continue;
104+
}
96105

97106
note.setInstrument(mapping.getInstrument());
98107
note.setMidiKey(MathUtil.clamp(key + KEYS_PER_OCTAVE * mapping.getOctaveModifier(), LOWEST_KEY, HIGHEST_KEY));
99108
}
100-
note.setVolume(((float) velocity / MAX_VELOCITY) * (float) channelVolumes[shortMessage.getChannel()] / MAX_VELOCITY);
101-
note.setPanning((float) (pan - CENTER_PAN) / CENTER_PAN);
102-
103-
if (skipOutOfRangeNotes && (note.getMidiKey() < NbsDefinitions.LOWEST_MIDI_KEY || note.getMidiKey() > NbsDefinitions.HIGHEST_MIDI_KEY)) {
109+
if (skipOutOfNbsRangeNotes && (note.getMidiKey() < NbsDefinitions.LOWEST_MIDI_KEY || note.getMidiKey() > NbsDefinitions.HIGHEST_MIDI_KEY)) {
104110
continue;
105111
}
106-
112+
note.setVolume(((float) velocity / MAX_VELOCITY) * ((float) volume / MAX_VELOCITY) * ((float) expression / MAX_VELOCITY));
113+
if (pan < CENTER_PAN) { // 0-63 (64 values) -> left
114+
note.setPanning((float) (pan - CENTER_PAN) / CENTER_PAN);
115+
} else if (pan > CENTER_PAN) { // 65-127 (63 values) -> right
116+
note.setPanning((float) (pan - CENTER_PAN) / (Byte.MAX_VALUE - CENTER_PAN));
117+
}
107118
song.getNotes().add((int) event.getTick(), note);
108119
break;
109120
case NOTE_OFF:
110121
// Ignore note off events
111122
break;
112123
case PROGRAM_CHANGE:
113-
channelInstruments[shortMessage.getChannel()] = (byte) shortMessage.getData1();
124+
channelInstruments[shortMessage.getChannel()] = (byte) Math.max((byte) shortMessage.getData1(), 0);
114125
break;
115126
case CONTROL_CHANGE:
116127
switch (shortMessage.getData1()) {
117-
case VOLUME_CONTROL_MSB:
118-
channelVolumes[shortMessage.getChannel()] = (byte) shortMessage.getData2();
128+
case CONTROL_CHANNEL_VOLUME_MSB:
129+
channelVolumes[shortMessage.getChannel()] = (byte) MathUtil.clamp(shortMessage.getData2(), 0, MAX_VELOCITY);
119130
break;
120-
case PAN_CONTROL_MSB:
121-
channelPans[shortMessage.getChannel()] = (byte) shortMessage.getData2();
131+
case CONTROL_PAN_MSB:
132+
channelPans[shortMessage.getChannel()] = (byte) MathUtil.clamp(shortMessage.getData2(), 0, Byte.MAX_VALUE);
122133
break;
123-
case RESET_CONTROLS:
124-
channelVolumes[shortMessage.getChannel()] = MAX_VELOCITY;
125-
channelPans[shortMessage.getChannel()] = CENTER_PAN;
134+
case CONTROL_EXPRESSION_CONTROLLER_MSB:
135+
channelExpressions[shortMessage.getChannel()] = (byte) MathUtil.clamp(shortMessage.getData2(), 0, Byte.MAX_VALUE);
136+
break;
137+
case CONTROL_RESET_ALL_CONTROLLERS:
138+
// Most MIDI synths don't reset volume and pan
139+
channelExpressions[shortMessage.getChannel()] = Byte.MAX_VALUE;
126140
break;
127141
}
128142
break;
129143
case PITCH_BEND:
130144
// Ignore pitch bend events
131145
break;
132-
case SYSTEM_RESET:
133-
Arrays.fill(channelInstruments, (byte) 0);
134-
Arrays.fill(channelVolumes, MAX_VELOCITY);
135-
Arrays.fill(channelPans, CENTER_PAN);
146+
case CHANNEL_PRESSURE:
147+
// Ignore channel pressure events
148+
break;
149+
case POLY_PRESSURE:
150+
// Ignore poly pressure events
136151
break;
152+
default:
153+
throw new IllegalStateException("Unsupported MIDI command: " + shortMessage.getCommand());
137154
}
138155
} else if (message instanceof MetaMessage) {
139156
final MetaMessage metaMessage = (MetaMessage) message;
140157
final byte[] data = metaMessage.getData();
141-
if (metaMessage.getType() == META_SET_TEMPO && data.length == 3 && sequence.getDivisionType() == Sequence.PPQ) {
142-
final int newMpq = ((data[0] & 0xFF) << 16) | ((data[1] & 0xFF) << 8) | (data[2] & 0xFF);
143-
final double microsPerTick = (double) newMpq / sequence.getResolution();
144-
song.getTempoEvents().set((int) event.getTick(), (float) (1_000_000D / microsPerTick));
145-
} else if (metaMessage.getType() == META_COPYRIGHT_NOTICE) {
146-
song.setOriginalAuthor(new String(data, StandardCharsets.US_ASCII));
147-
} else if (metaMessage.getType() == META_TRACK_NAME) {
148-
song.getTrackNames().put(trackIdx, new String(data, StandardCharsets.US_ASCII));
158+
switch (metaMessage.getType()) {
159+
case META_SET_TEMPO:
160+
if (data.length == 3 && sequence.getDivisionType() == Sequence.PPQ) {
161+
final int newMpq = ((data[0] & 0xFF) << 16) | ((data[1] & 0xFF) << 8) | (data[2] & 0xFF);
162+
final double microsPerTick = (double) newMpq / sequence.getResolution();
163+
song.getTempoEvents().set((int) event.getTick(), (float) (1_000_000D / microsPerTick));
164+
}
165+
break;
166+
case META_TEXT:
167+
final String text = Arrays.stream(new String(data, StandardCharsets.US_ASCII).split("\n"))
168+
.map(String::trim)
169+
.filter(line -> !line.isEmpty())
170+
.map(line -> "Text: " + line)
171+
.collect(Collectors.joining("\n"));
172+
if (!text.isEmpty()) {
173+
if (song.getDescription() == null) {
174+
song.setDescription(text);
175+
} else {
176+
song.setDescription(song.getDescription() + "\n" + text);
177+
}
178+
}
179+
break;
180+
case META_COPYRIGHT_NOTICE:
181+
final String copyright = Arrays.stream(new String(data, StandardCharsets.US_ASCII).split("\n"))
182+
.map(String::trim)
183+
.filter(line -> !line.isEmpty())
184+
.map(line -> "Copyright: " + line)
185+
.collect(Collectors.joining("\n"));
186+
if (!copyright.isEmpty()) {
187+
if (song.getDescription() == null) {
188+
song.setDescription(copyright);
189+
} else {
190+
song.setDescription(song.getDescription() + "\n" + copyright);
191+
}
192+
}
193+
break;
194+
case META_TRACK_NAME:
195+
final String trackName = Arrays.stream(new String(data, StandardCharsets.US_ASCII).split("\n"))
196+
.map(String::trim)
197+
.filter(line -> !line.isEmpty())
198+
.map(line -> "Track Name: " + line)
199+
.collect(Collectors.joining("\n"));
200+
if (!trackName.isEmpty()) {
201+
if (song.getDescription() == null) {
202+
song.setDescription(trackName);
203+
} else {
204+
song.setDescription(song.getDescription() + "\n" + trackName);
205+
}
206+
}
207+
break;
149208
}
209+
} else if (message instanceof SysexMessage) {
210+
final SysexMessage sysexMessage = (SysexMessage) message;
211+
if (sysexMessage.getStatus() == SysexMessage.SYSTEM_EXCLUSIVE) {
212+
final byte[] data = sysexMessage.getData();
213+
if (data.length == 4 && (data[0] & 0xFF) == SYSEX_UNIVERSAL_NON_REALTIME_MESSAGE && (data[1] & 0xFF) == SYSEX_DEVICE_ALL && (data[2] & 0xFF) == SYSEX_SUB_ID_GENERAL_MIDI) {
214+
final int subId2 = data[3] & 0xFF;
215+
if (subId2 == SYSEX_GENERAL_MIDI_GM1_SYSTEM_ON || subId2 == SYSEX_GENERAL_MIDI_GM2_SYSTEM_ON) {
216+
Arrays.fill(channelInstruments, (byte) 0);
217+
Arrays.fill(channelVolumes, DEFAULT_VOLUME);
218+
Arrays.fill(channelPans, CENTER_PAN);
219+
Arrays.fill(channelExpressions, Byte.MAX_VALUE);
220+
}
221+
}
222+
}
223+
} else {
224+
throw new IllegalStateException("Unsupported MIDI message type: " + message.getClass().getName());
150225
}
151226
}
152227
}
153228

154-
final float maxTempo = song.getTempoEvents().getTempoRange()[1];
155-
if (maxTempo > SONG_TARGET_TEMPO) {
156-
SongResampler.changeTickSpeed(song, SONG_TARGET_TEMPO);
229+
if (song.getTempoEvents().getTempoRange()[1] > SONG_TARGET_TEMPO) {
230+
final double[] times = SongResampler.getNotesByTime(song).keySet().stream().mapToDouble(Double::doubleValue).sorted().toArray();
231+
final double[] timeSpaces = IntStream.range(1, times.length).mapToDouble(i -> times[i] - times[i - 1]).sorted().toArray();
232+
if (timeSpaces.length > 0) {
233+
final float minTimeSpace = (float) timeSpaces[0];
234+
final float p05TimeSpace = (float) timeSpaces[timeSpaces.length / 20];
235+
final float p10TimeSpace = (float) timeSpaces[timeSpaces.length / 10];
236+
final float[] candidateTempos = new float[]{1000F / minTimeSpace, 1000F / p05TimeSpace, 1000F / p10TimeSpace};
237+
for (float candidateTempo : candidateTempos) {
238+
if (candidateTempo <= SONG_TARGET_TEMPO) {
239+
SongResampler.changeTickSpeed(song, candidateTempo);
240+
break;
241+
}
242+
}
243+
}
244+
if (song.getTempoEvents().getTempoRange()[1] > SONG_TARGET_TEMPO) {
245+
SongResampler.changeTickSpeed(song, SONG_TARGET_TEMPO);
246+
}
157247
}
158248

159249
return song;

src/main/java/net/raphimc/noteblocklib/format/midi/model/MidiSong.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525

2626
public class MidiSong extends Song {
2727

28+
@Deprecated
2829
private final Map<Integer, String> trackNames = new HashMap<>();
2930

3031
public MidiSong() {
@@ -35,6 +36,7 @@ public MidiSong(final String fileName) {
3536
super(SongFormat.MIDI, fileName);
3637
}
3738

39+
@Deprecated
3840
public Map<Integer, String> getTrackNames() {
3941
return this.trackNames;
4042
}
@@ -43,7 +45,6 @@ public Map<Integer, String> getTrackNames() {
4345
public MidiSong copy() {
4446
final MidiSong copySong = new MidiSong(this.getFileName());
4547
copySong.copyGeneralData(this);
46-
copySong.getTrackNames().putAll(this.getTrackNames());
4748
return copySong;
4849
}
4950

0 commit comments

Comments
 (0)