Skip to content

Commit a0301de

Browse files
committed
feat: improve songs
support all NBS versions
1 parent 4ebb81f commit a0301de

4 files changed

Lines changed: 284 additions & 154 deletions

File tree

type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/noteblock/Note.java

Lines changed: 0 additions & 57 deletions
This file was deleted.
Lines changed: 220 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,99 +1,255 @@
11
package net.swofty.type.skyblockgeneric.noteblock;
22

3-
import com.mongodb.lang.Nullable;
43
import lombok.Getter;
5-
import lombok.SneakyThrows;
4+
import net.kyori.adventure.key.Key;
65
import net.kyori.adventure.sound.Sound;
6+
import net.minestom.server.sound.SoundEvent;
77
import net.swofty.commons.Songs;
8-
import net.swofty.type.generic.utility.BufferUtility;
8+
import org.tinylog.Logger;
99

10+
import java.io.IOException;
1011
import java.nio.ByteBuffer;
1112
import java.nio.ByteOrder;
13+
import java.nio.charset.StandardCharsets;
1214
import java.nio.file.Files;
1315
import java.util.ArrayList;
16+
import java.util.Collections;
1417
import java.util.HashMap;
1518
import java.util.List;
1619
import java.util.Map;
20+
import java.util.NavigableMap;
21+
import java.util.TreeMap;
1722

1823
@Getter
1924
public class SkyBlockSong {
20-
private final Songs song;
25+
private static final int F_SHARP_4_KEY = 45;
26+
private static final int CENTER_PANNING = 100;
27+
private static final String TEMPO_CHANGER_CUSTOM_INSTRUMENT_NAME = "Tempo Changer";
28+
29+
private static final SoundEvent[] VANILLA_INSTRUMENTS = {SoundEvent.BLOCK_NOTE_BLOCK_HARP, SoundEvent.BLOCK_NOTE_BLOCK_BASS, SoundEvent.BLOCK_NOTE_BLOCK_BASEDRUM, SoundEvent.BLOCK_NOTE_BLOCK_SNARE, SoundEvent.BLOCK_NOTE_BLOCK_HAT, SoundEvent.BLOCK_NOTE_BLOCK_GUITAR, SoundEvent.BLOCK_NOTE_BLOCK_FLUTE, SoundEvent.BLOCK_NOTE_BLOCK_BELL, SoundEvent.BLOCK_NOTE_BLOCK_CHIME, SoundEvent.BLOCK_NOTE_BLOCK_XYLOPHONE, SoundEvent.BLOCK_NOTE_BLOCK_IRON_XYLOPHONE, SoundEvent.BLOCK_NOTE_BLOCK_COW_BELL, SoundEvent.BLOCK_NOTE_BLOCK_DIDGERIDOO, SoundEvent.BLOCK_NOTE_BLOCK_BIT, SoundEvent.BLOCK_NOTE_BLOCK_BANJO, SoundEvent.BLOCK_NOTE_BLOCK_PLING, SoundEvent.BLOCK_NOTE_BLOCK_IMITATE_CREEPER, SoundEvent.BLOCK_NOTE_BLOCK_IMITATE_ENDER_DRAGON, SoundEvent.BLOCK_NOTE_BLOCK_IMITATE_PIGLIN, SoundEvent.BLOCK_NOTE_BLOCK_IMITATE_SKELETON, SoundEvent.BLOCK_NOTE_BLOCK_IMITATE_WITHER_SKELETON, SoundEvent.BLOCK_NOTE_BLOCK_IMITATE_ZOMBIE,};
2130

22-
private final byte version;
23-
private final byte instrumentCount;
31+
private static final Map<String, SoundEvent> CUSTOM_SOUND_MAP = Map.ofEntries(Map.entry("minecraft/random/levelup.ogg", SoundEvent.ENTITY_PLAYER_LEVELUP), Map.entry("minecraft/block/bell/bell_use01.ogg", SoundEvent.BLOCK_BELL_USE));
32+
33+
private final Songs song;
2434
private final int length;
25-
private final int layerCount;
26-
private final String songName;
27-
private final String author;
28-
private final String originalAuthor;
29-
private final String description;
30-
private final double tps;
31-
private final byte timeSignature;
35+
private final float tps;
3236
private final boolean loop;
33-
private final byte maxLoopCount;
34-
private final int loopStart;
37+
private final int loopStartTick;
38+
private final NavigableMap<Integer, Float> tempoEvents;
39+
private final Map<Integer, Sound[]> ticks;
3540

36-
private final Map<Integer, List<Sound>> ticks;
37-
38-
@SneakyThrows
3941
public SkyBlockSong(Songs song) {
4042
this.song = song;
41-
ByteBuffer buffer = ByteBuffer.wrap(Files.readAllBytes(song.getPath())).order(ByteOrder.LITTLE_ENDIAN);
42-
43-
BufferUtility.getUnsignedShort(buffer); // first 2 bytes are always nothing in new versions of NBS files
44-
this.version = buffer.get();
45-
this.instrumentCount = buffer.get();
46-
this.length = BufferUtility.getUnsignedShort(buffer);
47-
this.layerCount = BufferUtility.getUnsignedShort(buffer);
48-
this.songName = BufferUtility.getNBSString(buffer);
49-
this.author = BufferUtility.getNBSString(buffer);
50-
this.originalAuthor = BufferUtility.getNBSString(buffer);
51-
this.description = BufferUtility.getNBSString(buffer);
52-
this.tps = BufferUtility.getUnsignedShort(buffer) / 100.0;
53-
buffer.get(); // auto saving
54-
buffer.get(); // auto saving duration
55-
this.timeSignature = buffer.get();
56-
buffer.getInt(); // minutes spent
57-
buffer.getInt(); // left clicks
58-
buffer.getInt(); // right clicks
59-
buffer.getInt(); // note blocks added
60-
buffer.getInt(); // note blocks removed
61-
BufferUtility.getNBSString(buffer); // midi file name
62-
this.loop = buffer.get() == 1;
63-
this.maxLoopCount = buffer.get();
64-
this.loopStart = BufferUtility.getUnsignedShort(buffer);
65-
66-
this.ticks = getTicks(buffer);
67-
}
43+
ByteBuffer buf;
44+
try {
45+
buf = ByteBuffer.wrap(Files.readAllBytes(song.getPath())).order(ByteOrder.LITTLE_ENDIAN);
46+
} catch (IOException e) {
47+
throw new SongLoadException("Failed to load song " + song.name() + ": " + e.getMessage());
48+
}
6849

69-
private Map<Integer, List<Sound>> getTicks(ByteBuffer buffer) {
70-
Map<Integer, List<Sound>> ticks = new HashMap<>(length);
50+
short firstShort = buf.getShort();
51+
int version;
52+
int vanillaInstrumentCount;
53+
int length;
54+
55+
if (firstShort == 0) {
56+
version = buf.get() & 0xFF;
57+
vanillaInstrumentCount = buf.get() & 0xFF;
58+
length = version >= 3 ? buf.getShort() : -1;
59+
} else {
60+
version = 0;
61+
vanillaInstrumentCount = 10;
62+
length = firstShort;
63+
}
7164

72-
int i = 0;
65+
if (version > 5) throw new SongLoadException("Unsupported NBS version: " + version);
66+
67+
int layerCount = Short.toUnsignedInt(buf.getShort());
68+
readString(buf);
69+
readString(buf);
70+
readString(buf);
71+
readString(buf);
72+
this.tps = Short.toUnsignedInt(buf.getShort()) / 100f;
73+
buf.get();
74+
buf.get();
75+
buf.get();
76+
buf.position(buf.position() + 20);
77+
readString(buf);
78+
79+
if (version >= 4) {
80+
this.loop = buf.get() == 1;
81+
buf.get();
82+
this.loopStartTick = Short.toUnsignedInt(buf.getShort());
83+
} else {
84+
this.loop = false;
85+
this.loopStartTick = 0;
86+
}
7387

88+
Map<Integer, Map<Integer, RawNote>> layers = new HashMap<>();
89+
int tick = -1;
90+
int maxTick = 0;
7491
while (true) {
75-
int jumps = BufferUtility.getUnsignedShort(buffer);
76-
if (jumps == 0) break;
77-
i += jumps;
92+
int jumpTicks = Short.toUnsignedInt(buf.getShort());
93+
if (jumpTicks == 0) break;
94+
tick += jumpTicks;
95+
if (tick > maxTick) maxTick = tick;
96+
97+
int layer = -1;
98+
while (true) {
99+
int jumpLayers = Short.toUnsignedInt(buf.getShort());
100+
if (jumpLayers == 0) break;
101+
layer += jumpLayers;
102+
103+
int instrument = buf.get() & 0xFF;
104+
int key = buf.get() & 0xFF;
105+
int velocity = 100;
106+
int panning = CENTER_PANNING;
107+
short pitch = 0;
108+
109+
if (version >= 4) {
110+
velocity = buf.get() & 0xFF;
111+
panning = buf.get() & 0xFF;
112+
pitch = buf.getShort();
113+
}
114+
115+
layers.computeIfAbsent(layer, _ -> new HashMap<>()).put(tick, new RawNote(instrument, key, velocity, panning, pitch));
116+
}
117+
}
78118

79-
List<Sound> tick = getNotes(buffer);
80-
ticks.put(i, tick);
119+
this.length = length >= 0 ? length : maxTick;
120+
121+
LayerMeta[] layerMeta = new LayerMeta[layerCount];
122+
if (buf.hasRemaining()) {
123+
for (int i = 0; i < layerCount; i++) {
124+
readString(buf);
125+
LayerStatus status = LayerStatus.NONE;
126+
if (version >= 4) {
127+
status = switch (buf.get() & 0xFF) {
128+
case 1 -> LayerStatus.LOCKED;
129+
case 2 -> LayerStatus.SOLO;
130+
default -> LayerStatus.NONE;
131+
};
132+
}
133+
int vol = buf.get() & 0xFF;
134+
int pan = version >= 2 ? (buf.get() & 0xFF) : CENTER_PANNING;
135+
layerMeta[i] = new LayerMeta(vol, pan, status);
136+
}
81137
}
82138

83-
return ticks;
84-
}
139+
List<CustomInstrument> customInstruments = new ArrayList<>();
140+
if (buf.hasRemaining()) {
141+
int count = buf.get() & 0xFF;
142+
for (int i = 0; i < count; i++) {
143+
String name = readString(buf);
144+
String soundPath = readString(buf);
145+
int instrumentPitch = buf.get() & 0xFF;
146+
buf.get();
147+
customInstruments.add(new CustomInstrument(name, soundPath, instrumentPitch));
148+
}
149+
}
85150

86-
private @Nullable List<Sound> getNotes(ByteBuffer buffer) {
87-
List<Sound> notes = new ArrayList<>();
151+
boolean hasSoloLayers = false;
152+
for (LayerMeta meta : layerMeta) {
153+
if (meta != null && meta.status == LayerStatus.SOLO) {
154+
hasSoloLayers = true;
155+
break;
156+
}
157+
}
88158

89-
while (true) {
90-
int jumps = BufferUtility.getUnsignedShort(buffer);
91-
if (jumps == 0) break;
159+
Map<Integer, List<Sound>> tickSounds = HashMap.newHashMap(maxTick);
160+
TreeMap<Integer, Float> tempoEvents = new TreeMap<>();
161+
tempoEvents.put(0, Math.max(this.tps, 0.01f));
162+
for (var layerEntry : layers.entrySet()) {
163+
int layerIdx = layerEntry.getKey();
164+
LayerMeta meta = layerIdx < layerMeta.length && layerMeta[layerIdx] != null ? layerMeta[layerIdx] : new LayerMeta(100, CENTER_PANNING, LayerStatus.NONE);
165+
166+
boolean muted = meta.status == LayerStatus.LOCKED || (hasSoloLayers && meta.status != LayerStatus.SOLO);
167+
168+
float layerVolFactor = Math.min(meta.volume / 100f, 1f);
169+
170+
for (var noteEntry : layerEntry.getValue().entrySet()) {
171+
BakedNote bakedNote = bakeNote(noteEntry.getValue(), layerVolFactor, vanillaInstrumentCount, customInstruments);
172+
Float tempoChange = bakedNote.tempoChange();
173+
if (tempoChange != null && tempoChange > 0f) {
174+
tempoEvents.put(noteEntry.getKey(), tempoChange);
175+
}
176+
177+
Sound sound = bakedNote.sound();
178+
if (sound != null && !muted) {
179+
tickSounds.computeIfAbsent(noteEntry.getKey(), _ -> new ArrayList<>()).add(sound);
180+
}
181+
}
182+
}
92183

93-
notes.add(Note.readNote(buffer).toSound(Sound.Source.RECORD));
184+
Map<Integer, Sound[]> bakedTicks = HashMap.newHashMap(tickSounds.size());
185+
tickSounds.forEach((t, sounds) -> bakedTicks.put(t, sounds.toArray(Sound[]::new)));
186+
this.tempoEvents = Collections.unmodifiableNavigableMap(tempoEvents);
187+
this.ticks = Collections.unmodifiableMap(bakedTicks);
188+
}
189+
190+
private static BakedNote bakeNote(RawNote note, float layerVolFactor, int vanillaInstrumentCount, List<CustomInstrument> customInstruments) {
191+
float effectiveKey = Math.clamp(note.key, 0, 87) + note.pitch / 100f;
192+
SoundEvent soundEvent = null;
193+
Key customKey = null;
194+
195+
if (note.instrument < vanillaInstrumentCount) {
196+
soundEvent = note.instrument < VANILLA_INSTRUMENTS.length ? VANILLA_INSTRUMENTS[note.instrument] : VANILLA_INSTRUMENTS[0];
197+
} else {
198+
int customIdx = note.instrument - vanillaInstrumentCount;
199+
if (customIdx >= 0 && customIdx < customInstruments.size()) {
200+
CustomInstrument ci = customInstruments.get(customIdx);
201+
202+
if (TEMPO_CHANGER_CUSTOM_INSTRUMENT_NAME.equals(ci.name)) {
203+
return new BakedNote(null, Math.abs(note.pitch / 15f));
204+
}
205+
206+
effectiveKey += ci.pitch - F_SHARP_4_KEY;
207+
208+
if (ci.soundPath != null && !ci.soundPath.isBlank()) {
209+
SoundEvent mapped = CUSTOM_SOUND_MAP.get(ci.soundPath);
210+
if (mapped != null) {
211+
soundEvent = mapped;
212+
} else if (!ci.soundPath.contains("/") && !ci.soundPath.contains(".ogg")) {
213+
String keyStr = ci.soundPath.contains(":") ? ci.soundPath : "minecraft:" + ci.soundPath;
214+
customKey = Key.key(keyStr);
215+
} else {
216+
Logger.warn("Unmapped custom sound '{}', skipping", ci.soundPath);
217+
return new BakedNote(null, null);
218+
}
219+
} else {
220+
soundEvent = VANILLA_INSTRUMENTS[0];
221+
}
222+
} else {
223+
soundEvent = VANILLA_INSTRUMENTS[0];
224+
}
94225
}
95226

96-
if (notes.isEmpty()) return null;
97-
return notes;
227+
float pitch = (float) Math.pow(2.0, (effectiveKey - F_SHARP_4_KEY) / 12.0);
228+
float volume = Math.clamp(layerVolFactor * (note.velocity / 100f), 0f, 1f);
229+
230+
Sound sound = customKey != null ? Sound.sound(customKey, Sound.Source.RECORD, volume, pitch) : Sound.sound(soundEvent, Sound.Source.RECORD, volume, pitch);
231+
return new BakedNote(sound, null);
232+
}
233+
234+
private static String readString(ByteBuffer buf) {
235+
int len = buf.getInt();
236+
if (len <= 0) return "";
237+
byte[] bytes = new byte[len];
238+
buf.get(bytes);
239+
return new String(bytes, StandardCharsets.UTF_8);
240+
}
241+
242+
private record RawNote(int instrument, int key, int velocity, int panning, short pitch) {
98243
}
99-
}
244+
245+
private record LayerMeta(int volume, int panning, LayerStatus status) {
246+
}
247+
248+
private record CustomInstrument(String name, String soundPath, int pitch) {
249+
}
250+
251+
private record BakedNote(Sound sound, Float tempoChange) {
252+
}
253+
254+
private enum LayerStatus {NONE, LOCKED, SOLO}
255+
}

0 commit comments

Comments
 (0)