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);