From bf680c380920e98075c152e14391bcc01153361c Mon Sep 17 00:00:00 2001 From: zishounekonanoda Date: Wed, 12 Aug 2026 01:49:37 +0900 Subject: [PATCH 1/5] Fix skipped notes at high tempos --- pom.xml | 6 + .../xxmicloxx/NoteBlockAPI/NoteBlockAPI.java | 6 +- .../songplayer/PlaybackClock.java | 22 ++++ .../NoteBlockAPI/songplayer/SongPlayer.java | 103 ++++++++++-------- .../songplayer/PlaybackClockTest.java | 55 ++++++++++ 5 files changed, 145 insertions(+), 47 deletions(-) create mode 100644 src/main/java/com/xxmicloxx/NoteBlockAPI/songplayer/PlaybackClock.java create mode 100644 src/test/java/com/xxmicloxx/NoteBlockAPI/songplayer/PlaybackClockTest.java 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/PlaybackClock.java b/src/main/java/com/xxmicloxx/NoteBlockAPI/songplayer/PlaybackClock.java new file mode 100644 index 0000000..20d785b --- /dev/null +++ b/src/main/java/com/xxmicloxx/NoteBlockAPI/songplayer/PlaybackClock.java @@ -0,0 +1,22 @@ +package com.xxmicloxx.NoteBlockAPI.songplayer; + +final class PlaybackClock { + + private static final double ROUNDING_EPSILON = 1.0e-9; + private double pendingTicks; + + int advance(double elapsedSeconds, float ticksPerSecond) { + if (elapsedSeconds <= 0 || ticksPerSecond <= 0) { + return 0; + } + + pendingTicks += elapsedSeconds * ticksPerSecond; + int ticksToPlay = (int) Math.floor(pendingTicks + ROUNDING_EPSILON); + pendingTicks -= ticksToPlay; + return ticksToPlay; + } + + void reset() { + pendingTicks = 0; + } +} diff --git a/src/main/java/com/xxmicloxx/NoteBlockAPI/songplayer/SongPlayer.java b/src/main/java/com/xxmicloxx/NoteBlockAPI/songplayer/SongPlayer.java index 8f4424e..9880cc4 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,9 @@ 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; + private long lastPlaybackNanos; protected NoteBlockAPI plugin; @@ -309,15 +313,27 @@ 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; - } + lastPlaybackNanos = System.nanoTime(); + playbackTask = Bukkit.getScheduler().runTaskTimer(plugin, () -> { + long currentNanos = System.nanoTime(); + double elapsedSeconds = (currentNanos - lastPlaybackNanos) / 1_000_000_000.0; + lastPlaybackNanos = currentNanos; + + lock.lock(); + try { + if (destroyed || NoteBlockAPI.getAPI().isDisabling()){ + playbackTask.cancel(); + return; + } + if (!playing && !fading) { + playbackClock.reset(); + return; + } + + int ticksToPlay = playbackClock.advance(elapsedSeconds, song.getSpeed()); + // High-tempo songs and server delays may require multiple song ticks in one server tick. + while (ticksToPlay-- > 0 && !destroyed) { if (playing || fading) { if (fadeTemp != null){ if (fadeTemp.isDone()) { @@ -430,51 +446,35 @@ private void start() { 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); + + try { + for (UUID uuid : playerList.keySet()) { + Player player = Bukkit.getPlayer(uuid); + if (player == null) { + // offline... + continue; } - } 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(); + playTick(player, tick); } - }); - } - } catch (Exception e) { - Bukkit.getLogger().severe("An error occurred during the playback of song " - + (song != null ? + } 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 (destroyed) { - break; - } - - long duration = System.currentTimeMillis() - startTime; - float delayMillis = song.getDelay() * 50; - if (duration < delayMillis) { - try { - Thread.sleep((long) (delayMillis - duration)); - } catch (InterruptedException e) { - // do nothing + 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(); } - }); + }, 0L, 1L); } private void checkPlaylistQueue(){ @@ -606,6 +606,9 @@ public void destroy() { } destroyed = true; playing = false; + if (playbackTask != null) { + playbackTask.cancel(); + } setTick((short) -1); CallUpdate("destroyed", destroyed); CallUpdate("playing", playing); @@ -642,6 +645,10 @@ public void setPlaying(boolean playing, boolean fade) { public void setPlaying(boolean playing, Fade fade) { if (this.playing == playing) return; + if (playing) { + playbackClock.reset(); + lastPlaybackNanos = System.nanoTime(); + } this.playing = playing; if (fade != null && fade.getType() != FadeType.NONE) { fadeTemp = new Fade(fade.getType(), fade.getFadeDuration()); @@ -675,6 +682,8 @@ public short getTick() { */ public void setTick(short tick) { this.tick = tick; + playbackClock.reset(); + lastPlaybackNanos = System.nanoTime(); CallUpdate("tick", tick); } @@ -794,6 +803,8 @@ public void playSong(int index){ song = playlist.get(index); actualSong = index; tick = -1; + playbackClock.reset(); + lastPlaybackNanos = System.nanoTime(); fadeIn.setFadeDone(0); fadeOut.setFadeDone(0); CallUpdate("song", song); 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..50dbabc --- /dev/null +++ b/src/test/java/com/xxmicloxx/NoteBlockAPI/songplayer/PlaybackClockTest.java @@ -0,0 +1,55 @@ +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() { + PlaybackClock clock = new PlaybackClock(); + int playedTicks = 0; + for (int serverTick = 0; serverTick < 2000; serverTick++) { + playedTicks += clock.advance(0.05, 31.86f); + } + + assertEquals(3186, playedTicks); + } + + @Test + public void catchesUpAllSongTicksAfterDelayedServerTick() { + PlaybackClock clock = new PlaybackClock(); + + assertEquals(8, clock.advance(0.2, 40.0f)); + } + + @Test + public void resetDiscardsPausedFractionWithoutSkippingFutureTicks() { + PlaybackClock clock = new PlaybackClock(); + assertEquals(0, clock.advance(0.01, 31.86f)); + clock.reset(); + + int playedTicks = 0; + for (int serverTick = 0; serverTick < 20; serverTick++) { + playedTicks += clock.advance(0.05, 20.0f); + } + assertEquals(20, playedTicks); + } + + private void assertTicksPlayedAfterOneSecond(float tempo, int expectedTicks) { + PlaybackClock clock = new PlaybackClock(); + int playedTicks = 0; + for (int serverTick = 0; serverTick < 20; serverTick++) { + playedTicks += clock.advance(0.05, tempo); + } + assertEquals(expectedTicks, playedTicks); + } +} From fa5c9874411049b8e2a711f7d37f07f0ec747dce Mon Sep 17 00:00:00 2001 From: zishounekonanoda Date: Wed, 12 Aug 2026 01:59:49 +0900 Subject: [PATCH 2/5] Address SonarQube maintainability findings --- .../songplayer/PlaybackClock.java | 22 -- .../NoteBlockAPI/songplayer/SongPlayer.java | 300 ++++++++++-------- .../songplayer/PlaybackClockTest.java | 8 +- 3 files changed, 173 insertions(+), 157 deletions(-) delete mode 100644 src/main/java/com/xxmicloxx/NoteBlockAPI/songplayer/PlaybackClock.java diff --git a/src/main/java/com/xxmicloxx/NoteBlockAPI/songplayer/PlaybackClock.java b/src/main/java/com/xxmicloxx/NoteBlockAPI/songplayer/PlaybackClock.java deleted file mode 100644 index 20d785b..0000000 --- a/src/main/java/com/xxmicloxx/NoteBlockAPI/songplayer/PlaybackClock.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.xxmicloxx.NoteBlockAPI.songplayer; - -final class PlaybackClock { - - private static final double ROUNDING_EPSILON = 1.0e-9; - private double pendingTicks; - - int advance(double elapsedSeconds, float ticksPerSecond) { - if (elapsedSeconds <= 0 || ticksPerSecond <= 0) { - return 0; - } - - pendingTicks += elapsedSeconds * ticksPerSecond; - int ticksToPlay = (int) Math.floor(pendingTicks + ROUNDING_EPSILON); - pendingTicks -= ticksToPlay; - return ticksToPlay; - } - - void reset() { - pendingTicks = 0; - } -} diff --git a/src/main/java/com/xxmicloxx/NoteBlockAPI/songplayer/SongPlayer.java b/src/main/java/com/xxmicloxx/NoteBlockAPI/songplayer/SongPlayer.java index 9880cc4..2d640f8 100644 --- a/src/main/java/com/xxmicloxx/NoteBlockAPI/songplayer/SongPlayer.java +++ b/src/main/java/com/xxmicloxx/NoteBlockAPI/songplayer/SongPlayer.java @@ -333,137 +333,8 @@ private void start() { int ticksToPlay = playbackClock.advance(elapsedSeconds, song.getSpeed()); // High-tempo songs and server delays may require multiple song ticks in one server tick. - while (ticksToPlay-- > 0 && !destroyed) { - 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); - - 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(); - } - } + while (ticksToPlay-- > 0 && !destroyed && (playing || fading)) { + playSongTick(); } } catch (Exception e) { Bukkit.getLogger().severe("An error occurred during the playback of song " @@ -477,6 +348,152 @@ private void start() { }, 0L, 1L); } + 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 (Song candidate : songQueue.keySet()) { + if (!songQueue.get(candidate)) { + left.add(candidate); + } + } + + 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(){ for (Song s : songQueue.keySet()){ if (!playlist.contains(s)){ @@ -900,6 +917,27 @@ public ChannelMode getChannelMode(){ return channelMode; } + static final class PlaybackClock { + + private static final double ROUNDING_EPSILON = 1.0e-9; + private double pendingTicks; + + int advance(double elapsedSeconds, float ticksPerSecond) { + if (elapsedSeconds <= 0 || ticksPerSecond <= 0) { + return 0; + } + + pendingTicks += elapsedSeconds * ticksPerSecond; + 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/test/java/com/xxmicloxx/NoteBlockAPI/songplayer/PlaybackClockTest.java b/src/test/java/com/xxmicloxx/NoteBlockAPI/songplayer/PlaybackClockTest.java index 50dbabc..bfc47a6 100644 --- a/src/test/java/com/xxmicloxx/NoteBlockAPI/songplayer/PlaybackClockTest.java +++ b/src/test/java/com/xxmicloxx/NoteBlockAPI/songplayer/PlaybackClockTest.java @@ -15,7 +15,7 @@ public void preservesEverySongTickAboveServerTickRate() { @Test public void preservesFractionalTempoOverTime() { - PlaybackClock clock = new PlaybackClock(); + SongPlayer.PlaybackClock clock = new SongPlayer.PlaybackClock(); int playedTicks = 0; for (int serverTick = 0; serverTick < 2000; serverTick++) { playedTicks += clock.advance(0.05, 31.86f); @@ -26,14 +26,14 @@ public void preservesFractionalTempoOverTime() { @Test public void catchesUpAllSongTicksAfterDelayedServerTick() { - PlaybackClock clock = new PlaybackClock(); + SongPlayer.PlaybackClock clock = new SongPlayer.PlaybackClock(); assertEquals(8, clock.advance(0.2, 40.0f)); } @Test public void resetDiscardsPausedFractionWithoutSkippingFutureTicks() { - PlaybackClock clock = new PlaybackClock(); + SongPlayer.PlaybackClock clock = new SongPlayer.PlaybackClock(); assertEquals(0, clock.advance(0.01, 31.86f)); clock.reset(); @@ -45,7 +45,7 @@ public void resetDiscardsPausedFractionWithoutSkippingFutureTicks() { } private void assertTicksPlayedAfterOneSecond(float tempo, int expectedTicks) { - PlaybackClock clock = new PlaybackClock(); + SongPlayer.PlaybackClock clock = new SongPlayer.PlaybackClock(); int playedTicks = 0; for (int serverTick = 0; serverTick < 20; serverTick++) { playedTicks += clock.advance(0.05, tempo); From 7c8c7f951cae6200047ee94e5e192fe4f27c269e Mon Sep 17 00:00:00 2001 From: zishounekonanoda Date: Wed, 12 Aug 2026 02:01:43 +0900 Subject: [PATCH 3/5] Clear remaining SonarQube warnings --- .../com/xxmicloxx/NoteBlockAPI/songplayer/SongPlayer.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/xxmicloxx/NoteBlockAPI/songplayer/SongPlayer.java b/src/main/java/com/xxmicloxx/NoteBlockAPI/songplayer/SongPlayer.java index 2d640f8..5874520 100644 --- a/src/main/java/com/xxmicloxx/NoteBlockAPI/songplayer/SongPlayer.java +++ b/src/main/java/com/xxmicloxx/NoteBlockAPI/songplayer/SongPlayer.java @@ -430,9 +430,9 @@ private boolean selectRandomSong() { songQueue.put(song, true); checkPlaylistQueue(); ArrayList left = new ArrayList<>(); - for (Song candidate : songQueue.keySet()) { - if (!songQueue.get(candidate)) { - left.add(candidate); + for (Map.Entry entry : songQueue.entrySet()) { + if (Boolean.FALSE.equals(entry.getValue())) { + left.add(entry.getKey()); } } From f1e975bc5ccfd80207606d03ff6aeae0f8370e56 Mon Sep 17 00:00:00 2001 From: zishounekonanoda Date: Wed, 12 Aug 2026 02:24:52 +0900 Subject: [PATCH 4/5] Stabilize playback timing against scheduler jitter --- .../NoteBlockAPI/songplayer/SongPlayer.java | 20 ++++++------------- .../songplayer/PlaybackClockTest.java | 15 +++++++------- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/xxmicloxx/NoteBlockAPI/songplayer/SongPlayer.java b/src/main/java/com/xxmicloxx/NoteBlockAPI/songplayer/SongPlayer.java index 5874520..3a268e4 100644 --- a/src/main/java/com/xxmicloxx/NoteBlockAPI/songplayer/SongPlayer.java +++ b/src/main/java/com/xxmicloxx/NoteBlockAPI/songplayer/SongPlayer.java @@ -49,7 +49,6 @@ public abstract class SongPlayer { private final Random rng = new Random(); private final PlaybackClock playbackClock = new PlaybackClock(); private BukkitTask playbackTask; - private long lastPlaybackNanos; protected NoteBlockAPI plugin; @@ -313,12 +312,7 @@ public void setEnable10Octave(boolean enable10Octave) { * Starts this SongPlayer */ private void start() { - lastPlaybackNanos = System.nanoTime(); playbackTask = Bukkit.getScheduler().runTaskTimer(plugin, () -> { - long currentNanos = System.nanoTime(); - double elapsedSeconds = (currentNanos - lastPlaybackNanos) / 1_000_000_000.0; - lastPlaybackNanos = currentNanos; - lock.lock(); try { if (destroyed || NoteBlockAPI.getAPI().isDisabling()){ @@ -331,8 +325,8 @@ private void start() { return; } - int ticksToPlay = playbackClock.advance(elapsedSeconds, song.getSpeed()); - // High-tempo songs and server delays may require multiple song ticks in one server tick. + 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(); } @@ -664,7 +658,6 @@ public void setPlaying(boolean playing, Fade fade) { if (playing) { playbackClock.reset(); - lastPlaybackNanos = System.nanoTime(); } this.playing = playing; if (fade != null && fade.getType() != FadeType.NONE) { @@ -700,7 +693,6 @@ public short getTick() { public void setTick(short tick) { this.tick = tick; playbackClock.reset(); - lastPlaybackNanos = System.nanoTime(); CallUpdate("tick", tick); } @@ -821,7 +813,6 @@ public void playSong(int index){ actualSong = index; tick = -1; playbackClock.reset(); - lastPlaybackNanos = System.nanoTime(); fadeIn.setFadeDone(0); fadeOut.setFadeDone(0); CallUpdate("song", song); @@ -919,15 +910,16 @@ public ChannelMode getChannelMode(){ 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(double elapsedSeconds, float ticksPerSecond) { - if (elapsedSeconds <= 0 || ticksPerSecond <= 0) { + int advance(float ticksPerSecond) { + if (ticksPerSecond <= 0) { return 0; } - pendingTicks += elapsedSeconds * ticksPerSecond; + pendingTicks += ticksPerSecond / SERVER_TICKS_PER_SECOND; int ticksToPlay = (int) Math.floor(pendingTicks + ROUNDING_EPSILON); pendingTicks -= ticksToPlay; return ticksToPlay; diff --git a/src/test/java/com/xxmicloxx/NoteBlockAPI/songplayer/PlaybackClockTest.java b/src/test/java/com/xxmicloxx/NoteBlockAPI/songplayer/PlaybackClockTest.java index bfc47a6..e6ea4ab 100644 --- a/src/test/java/com/xxmicloxx/NoteBlockAPI/songplayer/PlaybackClockTest.java +++ b/src/test/java/com/xxmicloxx/NoteBlockAPI/songplayer/PlaybackClockTest.java @@ -18,28 +18,29 @@ public void preservesFractionalTempoOverTime() { SongPlayer.PlaybackClock clock = new SongPlayer.PlaybackClock(); int playedTicks = 0; for (int serverTick = 0; serverTick < 2000; serverTick++) { - playedTicks += clock.advance(0.05, 31.86f); + playedTicks += clock.advance(31.86f); } assertEquals(3186, playedTicks); } @Test - public void catchesUpAllSongTicksAfterDelayedServerTick() { + public void playsExactlyOneSongTickPerServerTickAtTwentyTicksPerSecond() { SongPlayer.PlaybackClock clock = new SongPlayer.PlaybackClock(); - - assertEquals(8, clock.advance(0.2, 40.0f)); + 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(0.01, 31.86f)); + assertEquals(0, clock.advance(10.0f)); clock.reset(); int playedTicks = 0; for (int serverTick = 0; serverTick < 20; serverTick++) { - playedTicks += clock.advance(0.05, 20.0f); + playedTicks += clock.advance(20.0f); } assertEquals(20, playedTicks); } @@ -48,7 +49,7 @@ 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(0.05, tempo); + playedTicks += clock.advance(tempo); } assertEquals(expectedTicks, playedTicks); } From b3b3cbea6f179a8eed8ebc8a4fcb4feae504c1d7 Mon Sep 17 00:00:00 2001 From: zishounekonanoda Date: Wed, 12 Aug 2026 02:41:22 +0900 Subject: [PATCH 5/5] Fix negative fine pitch across octave boundaries --- .../NoteBlockAPI/utils/NoteUtils.java | 4 ++-- .../NoteBlockAPI/utils/NoteUtilsTest.java | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 src/test/java/com/xxmicloxx/NoteBlockAPI/utils/NoteUtilsTest.java 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/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); + } +}