Skip to content

Commit d85bc3e

Browse files
committed
Cleaned up code
1 parent 74dba2e commit d85bc3e

3 files changed

Lines changed: 266 additions & 213 deletions

File tree

src/main/java/net/raphimc/noteblocklib/NoteBlockLib.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ public static Song readSong(final Path path) throws Exception {
5050
}
5151

5252
public static Song readSong(final Path path, final SongFormat format) throws Exception {
53-
return readSong(Files.newInputStream(path), format, path.getFileName().toString());
53+
return readSong(Files.newInputStream(path), format, com.google.common.io.Files.getNameWithoutExtension(path.getFileName().toString()));
5454
}
5555

5656
public static Song readSong(final byte[] bytes, final SongFormat format) throws Exception {
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
/*
2+
* This file is part of NoteBlockLib - https://github.com/RaphiMC/NoteBlockLib
3+
* Copyright (C) 2022-2026 RK_01/RaphiMC and contributors
4+
*
5+
* This program is free software; you can redistribute it and/or
6+
* modify it under the terms of the GNU Lesser General Public
7+
* License as published by the Free Software Foundation; either
8+
* version 3 of the License, or (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
*/
18+
package net.raphimc.noteblocklib.format.midi;
19+
20+
import net.raphimc.noteblocklib.format.midi.mapping.InstrumentMapping;
21+
import net.raphimc.noteblocklib.format.midi.mapping.MidiMappings;
22+
import net.raphimc.noteblocklib.format.midi.mapping.PercussionMapping;
23+
import net.raphimc.noteblocklib.format.midi.model.MidiSong;
24+
import net.raphimc.noteblocklib.format.nbs.NbsDefinitions;
25+
import net.raphimc.noteblocklib.model.note.Note;
26+
import net.raphimc.noteblocklib.util.MathUtil;
27+
import net.raphimc.noteblocklib.util.SongResampler;
28+
29+
import javax.sound.midi.*;
30+
import java.nio.charset.StandardCharsets;
31+
import java.util.Arrays;
32+
import java.util.stream.Collectors;
33+
import java.util.stream.IntStream;
34+
35+
import static javax.sound.midi.ShortMessage.*;
36+
import static net.raphimc.noteblocklib.format.midi.MidiDefinitions.*;
37+
38+
public class MidiConverter {
39+
40+
/**
41+
* Creates a new MIDI song from a MIDI sequence.
42+
*
43+
* @param sequence The MIDI sequence
44+
* @param fileName The name of the file the MIDI sequence was read from or null
45+
* @return The new MIDI song
46+
*/
47+
public static MidiSong createSong(final Sequence sequence, final String fileName) {
48+
return createSong(sequence, fileName, true);
49+
}
50+
51+
/**
52+
* Creates a new MIDI song from a MIDI sequence.
53+
*
54+
* @param sequence The MIDI sequence
55+
* @param fileName The name of the file the MIDI sequence was read from or null.
56+
* @param skipOutOfNbsRangeNotes Whether to skip notes that are out of the NBS key range
57+
* @return The new MIDI song
58+
*/
59+
public static MidiSong createSong(final Sequence sequence, final String fileName, final boolean skipOutOfNbsRangeNotes) {
60+
if (sequence.getTickLength() > Integer.MAX_VALUE) {
61+
throw new IllegalArgumentException("MIDI sequence has too many ticks");
62+
}
63+
64+
final MidiSong song = new MidiSong(fileName);
65+
if (sequence.getDivisionType() == Sequence.PPQ) {
66+
song.getTempoEvents().set(0, (float) (1_000_000D / ((double) DEFAULT_TEMPO_MPQ / sequence.getResolution())));
67+
} else {
68+
song.getTempoEvents().set(0, sequence.getResolution() * sequence.getDivisionType());
69+
}
70+
71+
final byte[] channelInstruments = new byte[CHANNEL_COUNT];
72+
final byte[] channelVolumes = new byte[CHANNEL_COUNT];
73+
final byte[] channelPans = new byte[CHANNEL_COUNT];
74+
final byte[] channelExpressions = new byte[CHANNEL_COUNT];
75+
Arrays.fill(channelVolumes, DEFAULT_VOLUME);
76+
Arrays.fill(channelPans, CENTER_PAN);
77+
Arrays.fill(channelExpressions, Byte.MAX_VALUE);
78+
79+
for (int trackIdx = 0; trackIdx < sequence.getTracks().length; trackIdx++) {
80+
final Track track = sequence.getTracks()[trackIdx];
81+
for (int eventIdx = 0; eventIdx < track.size(); eventIdx++) {
82+
final MidiEvent event = track.get(eventIdx);
83+
final MidiMessage message = event.getMessage();
84+
85+
if (message instanceof ShortMessage) {
86+
final ShortMessage shortMessage = (ShortMessage) message;
87+
switch (shortMessage.getCommand()) {
88+
case NOTE_ON:
89+
final byte key = (byte) MathUtil.clamp(shortMessage.getData1(), LOWEST_KEY, HIGHEST_KEY);
90+
final byte velocity = (byte) MathUtil.clamp(shortMessage.getData2(), 0, MAX_VELOCITY);
91+
final byte instrument = channelInstruments[shortMessage.getChannel()];
92+
final byte volume = channelVolumes[shortMessage.getChannel()];
93+
final byte pan = channelPans[shortMessage.getChannel()];
94+
final byte expression = channelExpressions[shortMessage.getChannel()];
95+
96+
final Note note = new Note();
97+
if (shortMessage.getChannel() == PERCUSSION_CHANNEL) {
98+
final PercussionMapping mapping = MidiMappings.PERCUSSION_MAPPINGS[key];
99+
if (mapping == null) {
100+
continue;
101+
}
102+
103+
note.setInstrument(mapping.getInstrument());
104+
note.setNbsKey(mapping.getNbsKey());
105+
} else {
106+
final InstrumentMapping mapping = MidiMappings.INSTRUMENT_MAPPINGS[instrument];
107+
if (mapping == null) {
108+
continue;
109+
}
110+
111+
note.setInstrument(mapping.getInstrument());
112+
note.setMidiKey(MathUtil.clamp(key + KEYS_PER_OCTAVE * mapping.getOctaveModifier(), LOWEST_KEY, HIGHEST_KEY));
113+
}
114+
if (skipOutOfNbsRangeNotes && (note.getMidiKey() < NbsDefinitions.LOWEST_MIDI_KEY || note.getMidiKey() > NbsDefinitions.HIGHEST_MIDI_KEY)) {
115+
continue;
116+
}
117+
note.setVolume(((float) velocity / MAX_VELOCITY) * ((float) volume / MAX_VELOCITY) * ((float) expression / MAX_VELOCITY));
118+
if (pan < CENTER_PAN) { // 0-63 (64 values) -> left
119+
note.setPanning((float) (pan - CENTER_PAN) / CENTER_PAN);
120+
} else if (pan > CENTER_PAN) { // 65-127 (63 values) -> right
121+
note.setPanning((float) (pan - CENTER_PAN) / (Byte.MAX_VALUE - CENTER_PAN));
122+
}
123+
song.getNotes().add((int) event.getTick(), note);
124+
break;
125+
case NOTE_OFF:
126+
// Ignore note off events
127+
break;
128+
case PROGRAM_CHANGE:
129+
channelInstruments[shortMessage.getChannel()] = (byte) Math.max((byte) shortMessage.getData1(), 0);
130+
break;
131+
case CONTROL_CHANGE:
132+
switch (shortMessage.getData1()) {
133+
case CONTROL_CHANNEL_VOLUME_MSB:
134+
channelVolumes[shortMessage.getChannel()] = (byte) MathUtil.clamp(shortMessage.getData2(), 0, MAX_VELOCITY);
135+
break;
136+
case CONTROL_PAN_MSB:
137+
channelPans[shortMessage.getChannel()] = (byte) MathUtil.clamp(shortMessage.getData2(), 0, Byte.MAX_VALUE);
138+
break;
139+
case CONTROL_EXPRESSION_CONTROLLER_MSB:
140+
channelExpressions[shortMessage.getChannel()] = (byte) MathUtil.clamp(shortMessage.getData2(), 0, Byte.MAX_VALUE);
141+
break;
142+
case CONTROL_RESET_ALL_CONTROLLERS:
143+
// Most MIDI synths don't reset volume and pan
144+
channelExpressions[shortMessage.getChannel()] = Byte.MAX_VALUE;
145+
break;
146+
}
147+
break;
148+
case PITCH_BEND:
149+
// Ignore pitch bend events
150+
break;
151+
case CHANNEL_PRESSURE:
152+
// Ignore channel pressure events
153+
break;
154+
case POLY_PRESSURE:
155+
// Ignore poly pressure events
156+
break;
157+
default:
158+
throw new IllegalStateException("Unsupported MIDI command: " + shortMessage.getCommand());
159+
}
160+
} else if (message instanceof MetaMessage) {
161+
final MetaMessage metaMessage = (MetaMessage) message;
162+
final byte[] data = metaMessage.getData();
163+
switch (metaMessage.getType()) {
164+
case META_SET_TEMPO:
165+
if (data.length == 3 && sequence.getDivisionType() == Sequence.PPQ) {
166+
final int newMpq = ((data[0] & 0xFF) << 16) | ((data[1] & 0xFF) << 8) | (data[2] & 0xFF);
167+
final double microsPerTick = (double) newMpq / sequence.getResolution();
168+
song.getTempoEvents().set((int) event.getTick(), (float) (1_000_000D / microsPerTick));
169+
}
170+
break;
171+
case META_TEXT:
172+
final String text = Arrays.stream(new String(data, StandardCharsets.US_ASCII).split("\n"))
173+
.map(String::trim)
174+
.filter(line -> !line.isEmpty())
175+
.map(line -> "Text: " + line)
176+
.collect(Collectors.joining("\n"));
177+
if (!text.isEmpty()) {
178+
if (song.getDescription() == null) {
179+
song.setDescription(text);
180+
} else {
181+
song.setDescription(song.getDescription() + "\n" + text);
182+
}
183+
}
184+
break;
185+
case META_COPYRIGHT_NOTICE:
186+
final String copyright = Arrays.stream(new String(data, StandardCharsets.US_ASCII).split("\n"))
187+
.map(String::trim)
188+
.filter(line -> !line.isEmpty())
189+
.map(line -> "Copyright: " + line)
190+
.collect(Collectors.joining("\n"));
191+
if (!copyright.isEmpty()) {
192+
if (song.getDescription() == null) {
193+
song.setDescription(copyright);
194+
} else {
195+
song.setDescription(song.getDescription() + "\n" + copyright);
196+
}
197+
}
198+
break;
199+
case META_TRACK_NAME:
200+
final String trackName = Arrays.stream(new String(data, StandardCharsets.US_ASCII).split("\n"))
201+
.map(String::trim)
202+
.filter(line -> !line.isEmpty())
203+
.map(line -> "Track Name: " + line)
204+
.collect(Collectors.joining("\n"));
205+
if (!trackName.isEmpty()) {
206+
if (song.getDescription() == null) {
207+
song.setDescription(trackName);
208+
} else {
209+
song.setDescription(song.getDescription() + "\n" + trackName);
210+
}
211+
}
212+
break;
213+
}
214+
} else if (message instanceof SysexMessage) {
215+
final SysexMessage sysexMessage = (SysexMessage) message;
216+
if (sysexMessage.getStatus() == SysexMessage.SYSTEM_EXCLUSIVE) {
217+
final byte[] data = sysexMessage.getData();
218+
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) {
219+
final int subId2 = data[3] & 0xFF;
220+
if (subId2 == SYSEX_GENERAL_MIDI_GM1_SYSTEM_ON || subId2 == SYSEX_GENERAL_MIDI_GM2_SYSTEM_ON) {
221+
Arrays.fill(channelInstruments, (byte) 0);
222+
Arrays.fill(channelVolumes, DEFAULT_VOLUME);
223+
Arrays.fill(channelPans, CENTER_PAN);
224+
Arrays.fill(channelExpressions, Byte.MAX_VALUE);
225+
}
226+
}
227+
}
228+
} else {
229+
throw new IllegalStateException("Unsupported MIDI message type: " + message.getClass().getName());
230+
}
231+
}
232+
}
233+
234+
if (song.getTempoEvents().getTempoRange()[1] > SONG_TARGET_TEMPO) {
235+
final double[] times = SongResampler.getNotesByTime(song).keySet().stream().mapToDouble(Double::doubleValue).sorted().toArray();
236+
final double[] timeSpaces = IntStream.range(1, times.length).mapToDouble(i -> times[i] - times[i - 1]).sorted().toArray();
237+
if (timeSpaces.length > 0) {
238+
final float minTimeSpace = (float) timeSpaces[0];
239+
final float p05TimeSpace = (float) timeSpaces[timeSpaces.length / 20];
240+
final float p10TimeSpace = (float) timeSpaces[timeSpaces.length / 10];
241+
final float[] candidateTempos = new float[]{1000F / minTimeSpace, 1000F / p05TimeSpace, 1000F / p10TimeSpace};
242+
for (float candidateTempo : candidateTempos) {
243+
if (candidateTempo <= SONG_TARGET_TEMPO) {
244+
SongResampler.changeTickSpeed(song, candidateTempo);
245+
break;
246+
}
247+
}
248+
}
249+
if (song.getTempoEvents().getTempoRange()[1] > SONG_TARGET_TEMPO) {
250+
SongResampler.changeTickSpeed(song, SONG_TARGET_TEMPO);
251+
}
252+
}
253+
254+
return song;
255+
}
256+
257+
}

0 commit comments

Comments
 (0)