From e1f62ab50d95e93e1ee17b94bfb6539f2080c164 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Fri, 14 Aug 2026 11:56:27 +0000 Subject: [PATCH] Stop notification sounds queueing behind buffered silence The mixer wrote silence between sounds to keep the line running, which left the playback buffer (400 ms on the PipeWire ring) full of it, so a sound the user had just triggered only started once all of that had drained. Wait for the next sound instead, leaving the buffer empty between them. Opening the line costs a device round-trip of its own, so hold it for 30 s of quiet rather than dropping it after 3 s and paying that on the next action. Co-Authored-By: Claude Opus 5 --- .../audio/desktop/WavSoundPlayer.java | 54 ++++++++++++------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/WavSoundPlayer.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/WavSoundPlayer.java index be0479b..0fae90d 100644 --- a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/WavSoundPlayer.java +++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/WavSoundPlayer.java @@ -16,14 +16,18 @@ import java.util.Map; * playback device. * *

All notification sounds share one playback line and are mixed together, so - * two events firing at once never fight over the device and the line only exists - * while something is actually playing. Files are decoded once, resampled to the + * two events firing at once never fight over the device, and the line is dropped + * again after a long enough quiet spell. Files are decoded once, resampled to the * device's 48 kHz and cached, so repeat events cost nothing but the mix. */ public final class WavSoundPlayer implements SoundPlayer { - /** How long the playback line is kept open after the last sound, in mixer frames. */ - private static final int IDLE_FRAMES = 150; // 3 s of 20 ms frames + /** + * How long the playback line is held after the last sound. Opening it costs a + * device round-trip, which is audible as a lag before a sound the user just + * triggered, so it is kept around for a spell of quiet rather than per sound. + */ + private static final long IDLE_KEEP_OPEN_NANOS = 30_000_000_000L; /** Sound files are small; this caps the decoded cache anyway. */ private static final int MAX_CACHED_FILES = 64; @@ -97,31 +101,21 @@ public final class WavSoundPlayer implements SoundPlayer { // ---- mixing ---- /** - * Renders the active sounds onto one line until everything has been quiet for - * {@link #IDLE_FRAMES}, then gives the device back. + * Renders the active sounds onto one line, idling (rather than writing silence) + * between them and giving the device back after {@link #IDLE_KEEP_OPEN_NANOS}. + * + *

Idling matters for latency: the line buffers a few hundred milliseconds, so + * padding the gaps with silence would queue a sound behind all of it. */ private void mixLoop() { AudioPlayback line = null; try { - int idle = 0; float[] mix = new float[AudioDevices.FRAME_SIZE]; - while (true) { - synchronized (lock) { - if (!running) return; - if (voices.isEmpty()) { - if (++idle > IDLE_FRAMES) { - mixer = null; - return; - } - } else { - idle = 0; - } - } + while (awaitVoices()) { if (line == null) { line = AudioDevices.openPlayback(outputDevice, AudioDevices.MAX_CHANNELS); line.start(); } - // Silence keeps the line running (and paces this loop) until it is closed. renderFrame(mix); line.write(toBytes(mix, line.channels()), 0, mix.length * 2 * line.channels()); } @@ -135,6 +129,26 @@ public final class WavSoundPlayer implements SoundPlayer { } } + /** + * Blocks until there is something to render. + * + * @return false when the idle timeout expired or the player was shut down, in + * which case this thread is done and {@link #play} will start a fresh one + */ + private boolean awaitVoices() throws InterruptedException { + synchronized (lock) { + long deadline = System.nanoTime() + IDLE_KEEP_OPEN_NANOS; + while (running && voices.isEmpty()) { + long remaining = deadline - System.nanoTime(); + if (remaining <= 0) break; + lock.wait(Math.max(1, remaining / 1_000_000L)); + } + if (running && !voices.isEmpty()) return true; + mixer = null; + return false; + } + } + /** Sums the active sounds into {@code mix}, dropping the ones that ran out. */ private void renderFrame(float[] mix) { java.util.Arrays.fill(mix, 0f);