|
| 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