2 Commits
Author SHA1 Message Date
root 6e6a94c146 fix: use stable full-volume zone playback
Build / maven (push) Successful in 42s
Build and publish release / Build and publish Ambient Audio Zone (push) Successful in 44s
2026-08-04 06:15:13 +02:00
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
5 changed files with 18 additions and 13 deletions
+4 -3
View File
@@ -52,7 +52,7 @@ zones:
enabled: true enabled: true
``` ```
`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. The player hears the sound at the configured volume everywhere inside the cuboid. Entering starts playback and leaving stops it; `fade-distance` is retained for configuration compatibility but is not used for playback selection in this entry/exit mode. The sound is attached to the listener, so distance from `origin` does not change its volume.
## Overlaps and performance ## Overlaps and performance
@@ -61,13 +61,14 @@ By default, only the audible zone with the highest `priority` is selected. Set `
```yaml ```yaml
playback: playback:
check-interval-ticks: 10 check-interval-ticks: 10
leave-grace-ticks: 100 leave-grace-ticks: 0
blend-overlapping-zones: false blend-overlapping-zones: false
follow-player: true follow-player: true
fade-volume-updates: false
loop-restart-ticks: 2680 loop-restart-ticks: 2680
``` ```
When a selected zone remains selected, Ambient Audio Zone normally sends no new sound packet. In follow-player mode it uses rate-limited fade updates and stops the previous packet before re-emitting, preventing layered copies of the sound. Bukkit has no mutable-volume packet, so these updates are necessarily discrete; resource-pack loops are the smoothest option. 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. When a selected zone remains selected, Ambient Audio Zone sends no new sound packet, so walking cannot restart or stack the audio. Playback stops on the first update after leaving the cuboid. `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
@@ -21,11 +21,13 @@ public record AudioZone(String name, String worldName, String sound, Point origi
public Location originLocation(World world) { return new Location(world, origin.x(), origin.y(), origin.z()); } public Location originLocation(World world) { return new Location(world, origin.x(), origin.y(), origin.z()); }
public String normalizedName() { return name.toLowerCase(Locale.ROOT); } public String normalizedName() { return name.toLowerCase(Locale.ROOT); }
public double distanceToAudibleAreaSquared(Location location) { return region.distanceSquared(location.getX(), location.getY(), location.getZ()); } public double distanceToAudibleAreaSquared(Location location) { return region.distanceSquared(location.getX(), location.getY(), location.getZ()); }
public boolean isAudible(Location location) { return belongsTo(location.getWorld()) && distanceToAudibleAreaSquared(location) <= fadeDistance * fadeDistance; } /** Returns true only while the listener is inside the configured cuboid. */
public boolean isInside(Location location) { return belongsTo(location.getWorld()) && region.contains(location.getX(), location.getY(), location.getZ()); }
public record Point(double x, double y, double z) { } public record Point(double x, double y, double z) { }
/** Inclusive axis-aligned cuboid. */ /** Inclusive axis-aligned cuboid. */
public record Cuboid(double minX, double minY, double minZ, double maxX, double maxY, double maxZ) { public record Cuboid(double minX, double minY, double minZ, double maxX, double maxY, double maxZ) {
public Cuboid { if (minX > maxX || minY > maxY || minZ > maxZ) throw new IllegalArgumentException("Cuboid minimum exceeds maximum"); } public Cuboid { if (minX > maxX || minY > maxY || minZ > maxZ) throw new IllegalArgumentException("Cuboid minimum exceeds maximum"); }
public boolean contains(double x, double y, double z) { return x >= minX && x <= maxX && y >= minY && y <= maxY && z >= minZ && z <= maxZ; }
public double distanceSquared(double x, double y, double z) { public double distanceSquared(double x, double y, double z) {
double dx = delta(x, minX, maxX), dy = delta(y, minY, maxY), dz = delta(z, minZ, maxZ); double dx = delta(x, minX, maxX), dy = delta(y, minY, maxY), dz = delta(z, minZ, maxZ);
return dx * dx + dy * dy + dz * dz; return dx * dx + dy * dy + dz * dz;
@@ -12,13 +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 boolean followPlayer; private long leaveGraceTicks, loopRestartTicks; 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, long loopRestartTicks) { 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.loopRestartTicks = loopRestartTicks; 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);
} }
@@ -26,7 +26,7 @@ public final class PlaybackService {
private void tick() { Bukkit.getOnlinePlayers().forEach(this::update); } private void tick() { Bukkit.getOnlinePlayers().forEach(this::update); }
private void update(Player player) { private void update(Player player) {
long now = Bukkit.getCurrentTick(); long now = Bukkit.getCurrentTick();
List<AudioZone> audible = index.candidates(player.getLocation()).stream().filter(zone -> zone.isAudible(player.getLocation())).sorted(Comparator.comparingInt(AudioZone::priority).reversed()).toList(); List<AudioZone> audible = index.candidates(player.getLocation()).stream().filter(zone -> zone.isInside(player.getLocation())).sorted(Comparator.comparingInt(AudioZone::priority).reversed()).toList();
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<>());
@@ -39,7 +39,7 @@ public final class PlaybackService {
} else if (zone.loop() && loopRestartTicks > 0 && now - sound.startedAtTick() >= loopRestartTicks) { } else if (zone.loop() && loopRestartTicks > 0 && now - sound.startedAtTick() >= loopRestartTicks) {
play(player, zone, volume); play(player, zone, volume);
current.put(zone.normalizedName(), new ActiveSound(zone, Long.MAX_VALUE, now, now, volume)); current.put(zone.normalizedName(), new ActiveSound(zone, Long.MAX_VALUE, now, now, volume));
} else if (followPlayer && now - sound.lastPacketTick() >= 40 && Math.abs(sound.volume() - volume) >= 0.10f) { } 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 // 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. // packet first; otherwise repeated entity sound packets overlap on the client.
play(player, zone, volume); play(player, zone, volume);
@@ -70,7 +70,7 @@ public final class PlaybackService {
else player.playSound(zone.originLocation(player.getWorld()), zone.sound(), zone.category(), zone.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) { private float volumeFor(AudioZone zone, Player player) {
if (!followPlayer) return zone.volume(); if (!followPlayer || !fadeVolumeUpdates) return zone.volume();
double fade = zone.fadeDistance(); double fade = zone.fadeDistance();
double distance = Math.sqrt(zone.distanceToAudibleAreaSquared(player.getLocation())); double distance = Math.sqrt(zone.distanceToAudibleAreaSquared(player.getLocation()));
double multiplier = fade == 0 ? (distance == 0 ? 1 : 0) : Math.max(0, 1 - distance / fade); double multiplier = fade == 0 ? (distance == 0 ? 1 : 0) : Math.max(0, 1 - distance / fade);
@@ -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), c.getBoolean("playback.follow-player", true), Math.max(0, c.getLong("playback.loop-restart-ticks", 2680))); } private void refreshPlayback() { FileConfiguration c = plugin.getConfig(); playback.configure(zones.values(), Math.max(1, c.getInt("playback.check-interval-ticks", 10)), 0, c.getBoolean("playback.blend-overlapping-zones", false), true, false, Math.max(0, c.getLong("playback.loop-restart-ticks", 2680))); }
} }
+4 -2
View File
@@ -2,10 +2,12 @@
# "loop": true in sounds.json for true seamless, client-side looping. # "loop": true in sounds.json for true seamless, client-side looping.
playback: playback:
check-interval-ticks: 10 check-interval-ticks: 10
leave-grace-ticks: 100 leave-grace-ticks: 0
blend-overlapping-zones: false blend-overlapping-zones: false
# Entity-attached sounds follow each player instead of remaining at origin. # Sounds follow each player and remain at full configured volume while inside.
follow-player: true follow-player: true
# Volume adjustment is intentionally disabled: zones use entry/exit playback.
fade-volume-updates: false
# Fallback replay interval for loop: true sounds. 2680 ticks is 134 seconds. # 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. # Set to the actual length of custom sounds; resource-pack loop=true is preferred.
loop-restart-ticks: 2680 loop-restart-ticks: 2680