11package net .swofty .type .skyblockgeneric .noteblock ;
22
3- import com .mongodb .lang .Nullable ;
43import lombok .Getter ;
5- import lombok . SneakyThrows ;
4+ import net . kyori . adventure . key . Key ;
65import net .kyori .adventure .sound .Sound ;
6+ import net .minestom .server .sound .SoundEvent ;
77import net .swofty .commons .Songs ;
8- import net . swofty . type . generic . utility . BufferUtility ;
8+ import org . tinylog . Logger ;
99
10+ import java .io .IOException ;
1011import java .nio .ByteBuffer ;
1112import java .nio .ByteOrder ;
13+ import java .nio .charset .StandardCharsets ;
1214import java .nio .file .Files ;
1315import java .util .ArrayList ;
16+ import java .util .Collections ;
1417import java .util .HashMap ;
1518import java .util .List ;
1619import java .util .Map ;
20+ import java .util .NavigableMap ;
21+ import java .util .TreeMap ;
1722
1823@ Getter
1924public 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