3 Commits
Author SHA1 Message Date
root 247aa3cc90 fix: keep active sounds from restarting during movement
Build / maven (push) Successful in 43s
Build and publish release / Build and publish Ambient Audio Zone (push) Successful in 45s
2026-08-04 06:06:30 +02:00
root f11366088c fix: prevent overlapping fade and loop playback
Build / maven (push) Successful in 44s
Build and publish release / Build and publish Ambient Audio Zone (push) Successful in 44s
2026-08-04 05:55:41 +02:00
root b8215dffb9 fix: follow players and restart looping sounds
Build / maven (push) Successful in 44s
Build and publish release / Build and publish Ambient Audio Zone (push) Successful in 45s
2026-08-04 05:44:02 +02:00
4 changed files with 50 additions and 11 deletions
+5 -2
View File
@@ -52,7 +52,7 @@ zones:
enabled: true enabled: true
``` ```
`fade-distance` expands the cuboid's audible envelope. Outside that envelope the plugin does not select or start the zone. Within it, the sound packet is emitted at `origin`, so Minecraft's own positional audio supplies the continuous directional attenuation; set the origin at a speaker, stage, jukebox, or other real source rather than the cuboid centre. `fade-distance` expands the cuboid's audible envelope. With `playback.follow-player: true` (the default), the sound is attached to each listener and its volume fades from the configured volume inside the cuboid to silence at the edge of the fade envelope. Set it to `false` to use the fixed `origin` as a directional source instead.
## Overlaps and performance ## Overlaps and performance
@@ -63,9 +63,12 @@ playback:
check-interval-ticks: 10 check-interval-ticks: 10
leave-grace-ticks: 100 leave-grace-ticks: 100
blend-overlapping-zones: false blend-overlapping-zones: false
follow-player: true
fade-volume-updates: false
loop-restart-ticks: 2680
``` ```
When a selected zone remains selected, Ambient Audio Zone sends no new sound packet. Leaving starts a configurable grace timer; returning before it expires preserves the existing client playback instead of restarting it. Reloading or editing a zone intentionally replaces its active sound. When a selected zone remains selected, Ambient Audio Zone normally sends no new sound packet, so walking cannot restart or stack the audio. `fade-volume-updates` is disabled by default because Bukkit has no mutable-volume packet; enabling it approximates the fade by stopping and re-emitting the sound, which necessarily restarts it. For smooth native distance attenuation, disable `follow-player` and use a central `origin`. Leaving starts a configurable grace timer; returning before it expires preserves the existing client playback instead of restarting it. `loop-restart-ticks` is a fallback for sounds whose resource-pack definition does not loop; set it to the exact sound length, while resource-pack `loop: true` remains the seamless option. Reloading or editing a zone intentionally replaces its active sound.
## Resource-pack looping note ## Resource-pack looping note
@@ -12,11 +12,13 @@ import java.util.stream.Collectors;
public final class PlaybackService { public final class PlaybackService {
private final JavaPlugin plugin; private final ZoneIndex index = new ZoneIndex(); private final JavaPlugin plugin; private final ZoneIndex index = new ZoneIndex();
private final Map<UUID, Map<String, ActiveSound>> active = new HashMap<>(); private final Map<UUID, Map<String, ActiveSound>> active = new HashMap<>();
private boolean blending; private long leaveGraceTicks; private int taskId = -1; private boolean blending; private boolean followPlayer, fadeVolumeUpdates; private long leaveGraceTicks, loopRestartTicks; private int taskId = -1;
public PlaybackService(JavaPlugin plugin) { this.plugin = plugin; } public PlaybackService(JavaPlugin plugin) { this.plugin = plugin; }
public void configure(Collection<AudioZone> zones, int intervalTicks, long leaveGraceTicks, boolean blending) { public void configure(Collection<AudioZone> zones, int intervalTicks, long leaveGraceTicks, boolean blending,
boolean followPlayer, boolean fadeVolumeUpdates, long loopRestartTicks) {
stopAll(); index.rebuild(zones); this.blending = blending; this.leaveGraceTicks = leaveGraceTicks; stopAll(); index.rebuild(zones); this.blending = blending; this.leaveGraceTicks = leaveGraceTicks;
this.followPlayer = followPlayer; this.fadeVolumeUpdates = fadeVolumeUpdates; this.loopRestartTicks = loopRestartTicks;
if (taskId != -1) Bukkit.getScheduler().cancelTask(taskId); if (taskId != -1) Bukkit.getScheduler().cancelTask(taskId);
taskId = Bukkit.getScheduler().scheduleSyncRepeatingTask(plugin, this::tick, intervalTicks, intervalTicks); taskId = Bukkit.getScheduler().scheduleSyncRepeatingTask(plugin, this::tick, intervalTicks, intervalTicks);
} }
@@ -28,14 +30,26 @@ public final class PlaybackService {
if (!blending && !audible.isEmpty()) audible = List.of(audible.getFirst()); if (!blending && !audible.isEmpty()) audible = List.of(audible.getFirst());
Set<String> desired = audible.stream().map(AudioZone::normalizedName).collect(Collectors.toSet()); Set<String> desired = audible.stream().map(AudioZone::normalizedName).collect(Collectors.toSet());
Map<String, ActiveSound> current = active.computeIfAbsent(player.getUniqueId(), unused -> new HashMap<>()); Map<String, ActiveSound> current = active.computeIfAbsent(player.getUniqueId(), unused -> new HashMap<>());
for (AudioZone zone : audible) if (!current.containsKey(zone.normalizedName())) { for (AudioZone zone : audible) {
player.playSound(zone.originLocation(player.getWorld()), zone.sound(), zone.category(), zone.volume(), zone.pitch()); ActiveSound sound = current.get(zone.normalizedName());
current.put(zone.normalizedName(), new ActiveSound(zone, Long.MAX_VALUE)); float volume = volumeFor(zone, player);
if (sound == null) {
play(player, zone, volume);
current.put(zone.normalizedName(), new ActiveSound(zone, Long.MAX_VALUE, now, now, volume));
} else if (zone.loop() && loopRestartTicks > 0 && now - sound.startedAtTick() >= loopRestartTicks) {
play(player, zone, volume);
current.put(zone.normalizedName(), new ActiveSound(zone, Long.MAX_VALUE, now, now, volume));
} else if (followPlayer && fadeVolumeUpdates && now - sound.lastPacketTick() >= 40 && Math.abs(sound.volume() - volume) >= 0.10f) {
// Bukkit has no volume-update packet. Rate-limit fade packets and stop the previous
// packet first; otherwise repeated entity sound packets overlap on the client.
play(player, zone, volume);
current.put(zone.normalizedName(), new ActiveSound(zone, Long.MAX_VALUE, sound.startedAtTick(), now, volume));
}
} }
for (Iterator<Map.Entry<String, ActiveSound>> it = current.entrySet().iterator(); it.hasNext();) { for (Iterator<Map.Entry<String, ActiveSound>> it = current.entrySet().iterator(); it.hasNext();) {
Map.Entry<String, ActiveSound> entry = it.next(); ActiveSound sound = entry.getValue(); Map.Entry<String, ActiveSound> entry = it.next(); ActiveSound sound = entry.getValue();
if (desired.contains(entry.getKey())) { entry.setValue(new ActiveSound(sound.zone(), Long.MAX_VALUE)); continue; } if (desired.contains(entry.getKey())) { entry.setValue(new ActiveSound(sound.zone(), Long.MAX_VALUE, sound.startedAtTick(), sound.lastPacketTick(), sound.volume())); continue; }
if (sound.stopAtTick() == Long.MAX_VALUE) { entry.setValue(new ActiveSound(sound.zone(), now + leaveGraceTicks)); continue; } if (sound.stopAtTick() == Long.MAX_VALUE) { entry.setValue(new ActiveSound(sound.zone(), now + leaveGraceTicks, sound.startedAtTick(), sound.lastPacketTick(), sound.volume())); continue; }
if (now >= sound.stopAtTick()) { player.stopSound(sound.zone().sound(), sound.zone().category()); it.remove(); } if (now >= sound.stopAtTick()) { player.stopSound(sound.zone().sound(), sound.zone().category()); it.remove(); }
} }
if (current.isEmpty()) active.remove(player.getUniqueId()); if (current.isEmpty()) active.remove(player.getUniqueId());
@@ -48,5 +62,19 @@ public final class PlaybackService {
Map<String, ActiveSound> sounds = active.get(player.getUniqueId()); if (sounds != null) sounds.values().forEach(sound -> player.stopSound(sound.zone().sound(), sound.zone().category())); Map<String, ActiveSound> sounds = active.get(player.getUniqueId()); if (sounds != null) sounds.values().forEach(sound -> player.stopSound(sound.zone().sound(), sound.zone().category()));
} active.clear(); } active.clear();
} }
private record ActiveSound(AudioZone zone, long stopAtTick) { } private void play(Player player, AudioZone zone, float volume) {
// A sound event is not a handle. Stop the matching event before re-emitting it so
// fade updates and loop restarts cannot layer copies of the same sound.
player.stopSound(zone.sound(), zone.category());
if (followPlayer) player.playSound(player, zone.sound(), zone.category(), volume, zone.pitch());
else player.playSound(zone.originLocation(player.getWorld()), zone.sound(), zone.category(), zone.volume(), zone.pitch());
}
private float volumeFor(AudioZone zone, Player player) {
if (!followPlayer || !fadeVolumeUpdates) return zone.volume();
double fade = zone.fadeDistance();
double distance = Math.sqrt(zone.distanceToAudibleAreaSquared(player.getLocation()));
double multiplier = fade == 0 ? (distance == 0 ? 1 : 0) : Math.max(0, 1 - distance / fade);
return (float) Math.max(0.001, zone.volume() * multiplier);
}
private record ActiveSound(AudioZone zone, long stopAtTick, long startedAtTick, long lastPacketTick, float volume) { }
} }
@@ -18,5 +18,5 @@ public final class ZoneService {
public AudioZone get(String name) { return zones.get(name.toLowerCase(Locale.ROOT)); } public AudioZone get(String name) { return zones.get(name.toLowerCase(Locale.ROOT)); }
public void put(AudioZone zone) throws IOException { zones.put(zone.normalizedName(), zone); store.save(zones.values()); refreshPlayback(); } public void put(AudioZone zone) throws IOException { zones.put(zone.normalizedName(), zone); store.save(zones.values()); refreshPlayback(); }
public AudioZone remove(String name) throws IOException { AudioZone removed = zones.remove(name.toLowerCase(Locale.ROOT)); if (removed != null) { store.save(zones.values()); refreshPlayback(); } return removed; } public AudioZone remove(String name) throws IOException { AudioZone removed = zones.remove(name.toLowerCase(Locale.ROOT)); if (removed != null) { store.save(zones.values()); refreshPlayback(); } return removed; }
private void refreshPlayback() { FileConfiguration c = plugin.getConfig(); playback.configure(zones.values(), Math.max(1, c.getInt("playback.check-interval-ticks", 10)), Math.max(0, c.getLong("playback.leave-grace-ticks", 100)), c.getBoolean("playback.blend-overlapping-zones", false)); } private void refreshPlayback() { FileConfiguration c = plugin.getConfig(); playback.configure(zones.values(), Math.max(1, c.getInt("playback.check-interval-ticks", 10)), Math.max(0, c.getLong("playback.leave-grace-ticks", 100)), c.getBoolean("playback.blend-overlapping-zones", false), c.getBoolean("playback.follow-player", true), c.getBoolean("playback.fade-volume-updates", false), Math.max(0, c.getLong("playback.loop-restart-ticks", 2680))); }
} }
+8
View File
@@ -4,3 +4,11 @@ playback:
check-interval-ticks: 10 check-interval-ticks: 10
leave-grace-ticks: 100 leave-grace-ticks: 100
blend-overlapping-zones: false blend-overlapping-zones: false
# Entity-attached sounds follow each player instead of remaining at origin.
follow-player: true
# Disabled by default: Bukkit has no mutable-volume packet, so enabling this
# necessarily restarts the sound when the calculated volume changes.
fade-volume-updates: false
# Fallback replay interval for loop: true sounds. 2680 ticks is 134 seconds.
# Set to the actual length of custom sounds; resource-pack loop=true is preferred.
loop-restart-ticks: 2680