diff --git a/pom.xml b/pom.xml index ee2757d..37efb96 100644 --- a/pom.xml +++ b/pom.xml @@ -39,6 +39,12 @@ 3.0.0 compile + + junit + junit + 4.13.2 + test + diff --git a/src/main/java/com/xxmicloxx/NoteBlockAPI/NoteBlockAPI.java b/src/main/java/com/xxmicloxx/NoteBlockAPI/NoteBlockAPI.java index fa59d9a..3003ecc 100644 --- a/src/main/java/com/xxmicloxx/NoteBlockAPI/NoteBlockAPI.java +++ b/src/main/java/com/xxmicloxx/NoteBlockAPI/NoteBlockAPI.java @@ -204,7 +204,11 @@ public void onDisable() { } public void doSync(Runnable runnable) { - getServer().getScheduler().runTask(this, runnable); + if (Bukkit.isPrimaryThread()) { + runnable.run(); + } else { + getServer().getScheduler().runTask(this, runnable); + } } public void doAsync(Runnable runnable) { diff --git a/src/main/java/com/xxmicloxx/NoteBlockAPI/songplayer/SongPlayer.java b/src/main/java/com/xxmicloxx/NoteBlockAPI/songplayer/SongPlayer.java index 8f4424e..3a268e4 100644 --- a/src/main/java/com/xxmicloxx/NoteBlockAPI/songplayer/SongPlayer.java +++ b/src/main/java/com/xxmicloxx/NoteBlockAPI/songplayer/SongPlayer.java @@ -7,6 +7,7 @@ import com.xxmicloxx.NoteBlockAPI.model.playmode.MonoMode; import org.bukkit.Bukkit; import org.bukkit.entity.Player; +import org.bukkit.scheduler.BukkitTask; import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; @@ -46,6 +47,8 @@ public abstract class SongPlayer { private final Lock lock = new ReentrantLock(); private final Random rng = new Random(); + private final PlaybackClock playbackClock = new PlaybackClock(); + private BukkitTask playbackTask; protected NoteBlockAPI plugin; @@ -309,172 +312,180 @@ public void setEnable10Octave(boolean enable10Octave) { * Starts this SongPlayer */ private void start() { - plugin.doAsync(() -> { - while (!destroyed) { - long startTime = System.currentTimeMillis(); - lock.lock(); - try { - if (destroyed || NoteBlockAPI.getAPI().isDisabling()){ - break; - } + playbackTask = Bukkit.getScheduler().runTaskTimer(plugin, () -> { + lock.lock(); + try { + if (destroyed || NoteBlockAPI.getAPI().isDisabling()){ + playbackTask.cancel(); + return; + } - if (playing || fading) { - if (fadeTemp != null){ - if (fadeTemp.isDone()) { - fadeTemp = null; - fading = false; - if (!playing) { - SongStoppedEvent event = new SongStoppedEvent(this); - plugin.doSync(() -> Bukkit.getPluginManager().callEvent(event)); - volume = fadeIn.getFadeTarget(); - continue; - } - }else { - int fade = fadeTemp.calculateFade(); - if (fade != -1){ - volume = (byte) fade; - } - } - } else if (tick < fadeIn.getFadeDuration()){ - int fade = fadeIn.calculateFade(); - if (fade != -1){ - volume = (byte) fade; - } - CallUpdate("fadeDone", fadeIn.getFadeDone()); - } else if (tick >= song.getLength() - fadeOut.getFadeDuration()){ - int fade = fadeOut.calculateFade(); - if (fade != -1){ - volume = (byte) fade; - } - } - - tick++; - if (tick > song.getLength()) { - tick = -1; - fadeIn.setFadeDone(0); - CallUpdate("fadeDone", fadeIn.getFadeDone()); - fadeOut.setFadeDone(0); - volume = fadeIn.getFadeTarget(); - if (repeat == RepeatMode.ONE){ - SongLoopEvent event = new SongLoopEvent(this); - plugin.doSync(() -> Bukkit.getPluginManager().callEvent(event)); - - if (!event.isCancelled()) { - continue; - } - } else { - if (random) { - songQueue.put(song, true); - checkPlaylistQueue(); - ArrayList left = new ArrayList<>(); - for (Song s : songQueue.keySet()) { - if (!songQueue.get(s)) { - left.add(s); - } - } - - if (left.size() == 0) { - left.addAll(songQueue.keySet()); - for (Song s : songQueue.keySet()) { - songQueue.put(s, false); - } - song = left.get(rng.nextInt(left.size())); - actualSong = playlist.getIndex(song); - CallUpdate("song", song); - if (repeat == RepeatMode.ALL) { - SongLoopEvent event = new SongLoopEvent(this); - plugin.doSync(() -> Bukkit.getPluginManager().callEvent(event)); - - if (!event.isCancelled()) { - continue; - } - } - } else { - song = left.get(rng.nextInt(left.size())); - actualSong = playlist.getIndex(song); - - CallUpdate("song", song); - SongNextEvent event = new SongNextEvent(this); - plugin.doSync(() -> Bukkit.getPluginManager().callEvent(event)); - continue; - } - } else { - if (playlist.hasNext(actualSong)) { - actualSong++; - song = playlist.get(actualSong); - CallUpdate("song", song); - SongNextEvent event = new SongNextEvent(this); - plugin.doSync(() -> Bukkit.getPluginManager().callEvent(event)); - continue; - } else { - actualSong = 0; - song = playlist.get(actualSong); - CallUpdate("song", song); - if (repeat == RepeatMode.ALL) { - SongLoopEvent event = new SongLoopEvent(this); - plugin.doSync(() -> Bukkit.getPluginManager().callEvent(event)); - - if (!event.isCancelled()) { - continue; - } - } - } - } - } - playing = false; - SongEndEvent event = new SongEndEvent(this); - plugin.doSync(() -> Bukkit.getPluginManager().callEvent(event)); - if (autoDestroy) { - destroy(); - } - continue; - } - CallUpdate("tick", tick); - - plugin.doSync(() -> { - try { - for (UUID uuid : playerList.keySet()) { - Player player = Bukkit.getPlayer(uuid); - if (player == null) { - // offline... - continue; - } - playTick(player, tick); - } - } catch (Exception e){ - Bukkit.getLogger().severe("An error occurred during the playback of song " - + (song != null ? - song.getPath() + " (" + song.getAuthor() + " - " + song.getTitle() + ")" - : "null")); - e.printStackTrace(); - } - }); - } - } catch (Exception e) { - Bukkit.getLogger().severe("An error occurred during the playback of song " - + (song != null ? - song.getPath() + " (" + song.getAuthor() + " - " + song.getTitle() + ")" - : "null")); - e.printStackTrace(); - } finally { - lock.unlock(); + if (!playing && !fading) { + playbackClock.reset(); + return; } - if (destroyed) { - break; + int ticksToPlay = playbackClock.advance(song.getSpeed()); + // High-tempo songs may require multiple song ticks in one server tick. + while (ticksToPlay-- > 0 && !destroyed && (playing || fading)) { + playSongTick(); } + } catch (Exception e) { + Bukkit.getLogger().severe("An error occurred during the playback of song " + + (song != null ? + song.getPath() + " (" + song.getAuthor() + " - " + song.getTitle() + ")" + : "null")); + e.printStackTrace(); + } finally { + lock.unlock(); + } + }, 0L, 1L); + } - long duration = System.currentTimeMillis() - startTime; - float delayMillis = song.getDelay() * 50; - if (duration < delayMillis) { - try { - Thread.sleep((long) (delayMillis - duration)); - } catch (InterruptedException e) { - // do nothing - } + private void playSongTick() { + if (!updateFade()) { + tick++; + if (tick > song.getLength()) { + handleSongEnd(); + } else { + CallUpdate("tick", tick); + playCurrentTickForPlayers(); + } + } + } + + private boolean updateFade() { + boolean skipTick = false; + if (fadeTemp != null){ + if (fadeTemp.isDone()) { + fadeTemp = null; + fading = false; + if (!playing) { + SongStoppedEvent event = new SongStoppedEvent(this); + plugin.doSync(() -> Bukkit.getPluginManager().callEvent(event)); + volume = fadeIn.getFadeTarget(); + skipTick = true; + } + } else { + applyFadeVolume(fadeTemp.calculateFade()); + } + } else if (tick < fadeIn.getFadeDuration()){ + applyFadeVolume(fadeIn.calculateFade()); + CallUpdate("fadeDone", fadeIn.getFadeDone()); + } else if (tick >= song.getLength() - fadeOut.getFadeDuration()){ + applyFadeVolume(fadeOut.calculateFade()); + } + return skipTick; + } + + private void applyFadeVolume(int fade) { + if (fade != -1){ + volume = (byte) fade; + } + } + + private void handleSongEnd() { + tick = -1; + fadeIn.setFadeDone(0); + CallUpdate("fadeDone", fadeIn.getFadeDone()); + fadeOut.setFadeDone(0); + volume = fadeIn.getFadeTarget(); + + if (!prepareFollowingSong()) { + playing = false; + SongEndEvent event = new SongEndEvent(this); + plugin.doSync(() -> Bukkit.getPluginManager().callEvent(event)); + if (autoDestroy) { + destroy(); + } + } + } + + private boolean prepareFollowingSong() { + boolean continuePlaying = false; + if (repeat == RepeatMode.ONE){ + continuePlaying = callSongLoopEvent(); + } else if (random) { + continuePlaying = selectRandomSong(); + } else if (playlist.hasNext(actualSong)) { + selectSong(actualSong + 1); + callSongNextEvent(); + continuePlaying = true; + } else { + selectSong(0); + if (repeat == RepeatMode.ALL) { + continuePlaying = callSongLoopEvent(); + } + } + return continuePlaying; + } + + private boolean selectRandomSong() { + songQueue.put(song, true); + checkPlaylistQueue(); + ArrayList left = new ArrayList<>(); + for (Map.Entry entry : songQueue.entrySet()) { + if (Boolean.FALSE.equals(entry.getValue())) { + left.add(entry.getKey()); + } + } + + boolean continuePlaying; + if (left.isEmpty()) { + left.addAll(songQueue.keySet()); + for (Song candidate : songQueue.keySet()) { + songQueue.put(candidate, false); + } + selectSong(left.get(rng.nextInt(left.size()))); + continuePlaying = repeat == RepeatMode.ALL && callSongLoopEvent(); + } else { + selectSong(left.get(rng.nextInt(left.size()))); + callSongNextEvent(); + continuePlaying = true; + } + return continuePlaying; + } + + private void selectSong(int index) { + song = playlist.get(index); + actualSong = index; + CallUpdate("song", song); + } + + private void selectSong(Song selectedSong) { + song = selectedSong; + actualSong = playlist.getIndex(song); + CallUpdate("song", song); + } + + private boolean callSongLoopEvent() { + SongLoopEvent event = new SongLoopEvent(this); + plugin.doSync(() -> Bukkit.getPluginManager().callEvent(event)); + return !event.isCancelled(); + } + + private void callSongNextEvent() { + SongNextEvent event = new SongNextEvent(this); + plugin.doSync(() -> Bukkit.getPluginManager().callEvent(event)); + } + + private void playCurrentTickForPlayers() { + try { + for (UUID uuid : playerList.keySet()) { + Player player = Bukkit.getPlayer(uuid); + if (player == null) { + // offline... + continue; } + playTick(player, tick); } - }); + } catch (Exception e){ + Bukkit.getLogger().severe("An error occurred during the playback of song " + + (song != null ? + song.getPath() + " (" + song.getAuthor() + " - " + song.getTitle() + ")" + : "null")); + e.printStackTrace(); + } } private void checkPlaylistQueue(){ @@ -606,6 +617,9 @@ public void destroy() { } destroyed = true; playing = false; + if (playbackTask != null) { + playbackTask.cancel(); + } setTick((short) -1); CallUpdate("destroyed", destroyed); CallUpdate("playing", playing); @@ -642,6 +656,9 @@ public void setPlaying(boolean playing, boolean fade) { public void setPlaying(boolean playing, Fade fade) { if (this.playing == playing) return; + if (playing) { + playbackClock.reset(); + } this.playing = playing; if (fade != null && fade.getType() != FadeType.NONE) { fadeTemp = new Fade(fade.getType(), fade.getFadeDuration()); @@ -675,6 +692,7 @@ public short getTick() { */ public void setTick(short tick) { this.tick = tick; + playbackClock.reset(); CallUpdate("tick", tick); } @@ -794,6 +812,7 @@ public void playSong(int index){ song = playlist.get(index); actualSong = index; tick = -1; + playbackClock.reset(); fadeIn.setFadeDone(0); fadeOut.setFadeDone(0); CallUpdate("song", song); @@ -889,6 +908,28 @@ public ChannelMode getChannelMode(){ return channelMode; } + static final class PlaybackClock { + + private static final double SERVER_TICKS_PER_SECOND = 20.0; + private static final double ROUNDING_EPSILON = 1.0e-9; + private double pendingTicks; + + int advance(float ticksPerSecond) { + if (ticksPerSecond <= 0) { + return 0; + } + + pendingTicks += ticksPerSecond / SERVER_TICKS_PER_SECOND; + int ticksToPlay = (int) Math.floor(pendingTicks + ROUNDING_EPSILON); + pendingTicks -= ticksToPlay; + return ticksToPlay; + } + + void reset() { + pendingTicks = 0; + } + } + void CallUpdate(String key, Object value){ if (oldSongPlayer == null){ return; diff --git a/src/main/java/com/xxmicloxx/NoteBlockAPI/utils/NoteUtils.java b/src/main/java/com/xxmicloxx/NoteBlockAPI/utils/NoteUtils.java index 4b5994e..8432212 100644 --- a/src/main/java/com/xxmicloxx/NoteBlockAPI/utils/NoteUtils.java +++ b/src/main/java/com/xxmicloxx/NoteBlockAPI/utils/NoteUtils.java @@ -44,7 +44,7 @@ public static float getPitchInOctave(Note note) { public static float getPitchInOctave(byte key, short pitch) { // Apply pitch to key key = applyPitchToKey(key, pitch); - pitch %= 100; + pitch = (short) Math.floorMod(pitch, 100); // -15 base_-2 // 9 base_-1 @@ -62,7 +62,7 @@ public static float getPitchInOctave(byte key, short pitch) { } public static byte applyPitchToKey(byte key, short pitch) { - key += pitch / 100; + key += Math.floorDiv(pitch, 100); return key; } diff --git a/src/test/java/com/xxmicloxx/NoteBlockAPI/songplayer/PlaybackClockTest.java b/src/test/java/com/xxmicloxx/NoteBlockAPI/songplayer/PlaybackClockTest.java new file mode 100644 index 0000000..e6ea4ab --- /dev/null +++ b/src/test/java/com/xxmicloxx/NoteBlockAPI/songplayer/PlaybackClockTest.java @@ -0,0 +1,56 @@ +package com.xxmicloxx.NoteBlockAPI.songplayer; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class PlaybackClockTest { + + @Test + public void preservesEverySongTickAboveServerTickRate() { + assertTicksPlayedAfterOneSecond(31.86f, 31); + assertTicksPlayedAfterOneSecond(40.0f, 40); + assertTicksPlayedAfterOneSecond(60.0f, 60); + } + + @Test + public void preservesFractionalTempoOverTime() { + SongPlayer.PlaybackClock clock = new SongPlayer.PlaybackClock(); + int playedTicks = 0; + for (int serverTick = 0; serverTick < 2000; serverTick++) { + playedTicks += clock.advance(31.86f); + } + + assertEquals(3186, playedTicks); + } + + @Test + public void playsExactlyOneSongTickPerServerTickAtTwentyTicksPerSecond() { + SongPlayer.PlaybackClock clock = new SongPlayer.PlaybackClock(); + for (int serverTick = 0; serverTick < 100; serverTick++) { + assertEquals(1, clock.advance(20.0f)); + } + } + + @Test + public void resetDiscardsPausedFractionWithoutSkippingFutureTicks() { + SongPlayer.PlaybackClock clock = new SongPlayer.PlaybackClock(); + assertEquals(0, clock.advance(10.0f)); + clock.reset(); + + int playedTicks = 0; + for (int serverTick = 0; serverTick < 20; serverTick++) { + playedTicks += clock.advance(20.0f); + } + assertEquals(20, playedTicks); + } + + private void assertTicksPlayedAfterOneSecond(float tempo, int expectedTicks) { + SongPlayer.PlaybackClock clock = new SongPlayer.PlaybackClock(); + int playedTicks = 0; + for (int serverTick = 0; serverTick < 20; serverTick++) { + playedTicks += clock.advance(tempo); + } + assertEquals(expectedTicks, playedTicks); + } +} diff --git a/src/test/java/com/xxmicloxx/NoteBlockAPI/utils/NoteUtilsTest.java b/src/test/java/com/xxmicloxx/NoteBlockAPI/utils/NoteUtilsTest.java new file mode 100644 index 0000000..913bf07 --- /dev/null +++ b/src/test/java/com/xxmicloxx/NoteBlockAPI/utils/NoteUtilsTest.java @@ -0,0 +1,24 @@ +package com.xxmicloxx.NoteBlockAPI.utils; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class NoteUtilsTest { + + @Test + public void negativeFinePitchMovesToPreviousKeyWithoutIndexUnderflow() { + assertEquals(32, NoteUtils.applyPitchToKey((byte) 33, (short) -30)); + assertEquals(expectedPitch(2370), NoteUtils.getPitchInOctave((byte) 33, (short) -30), 0.000001f); + } + + @Test + public void negativeFinePitchSelectsThePreviousOctaveSample() { + assertEquals("test_-1", InstrumentUtils.warpNameOutOfRange("test", (byte) 33, (short) -30)); + assertEquals("test", InstrumentUtils.warpNameOutOfRange("test", (byte) 57, (short) -30)); + } + + private float expectedPitch(int index) { + return (float) Math.pow(2, (index - 1200d) / 1200d); + } +}