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 0fae90d..643cc58 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 @@ -28,6 +28,14 @@ public final class WavSoundPlayer implements SoundPlayer { * 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; + private static final long FRAME_NANOS = AudioDevices.FRAME_SIZE * 1_000_000_000L / AudioDevices.SAMPLE_RATE; + /** + * How far ahead of real time the mixer renders. The line would happily take a + * few hundred milliseconds at once, but anything written is fixed: a sound that + * fires while another plays can only join the mix past what is already queued, + * so the lead is kept to a couple of frames, enough to ride out scheduling jitter. + */ + private static final long LEAD_NANOS = 3 * FRAME_NANOS; /** Sound files are small; this caps the decoded cache anyway. */ private static final int MAX_CACHED_FILES = 64; @@ -104,18 +112,29 @@ public final class WavSoundPlayer implements SoundPlayer { * 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. + *
Latency matters here because a sound is heard against the click that caused + * it: the loop idles rather than writing silence, and while rendering it paces + * itself against the clock instead of filling the line, so at any moment no more + * than {@link #LEAD_NANOS} of audio is committed and a newly fired sound starts + * right behind it. */ private void mixLoop() { AudioPlayback line = null; try { float[] mix = new float[AudioDevices.FRAME_SIZE]; + long due = 0; // when the next frame is due to leave the speaker while (awaitVoices()) { if (line == null) { line = AudioDevices.openPlayback(outputDevice, AudioDevices.MAX_CHANNELS); line.start(); } + long now = System.nanoTime(); + if (due < now) { + due = now; // fresh start or fell behind: resync + } else if (due - now > LEAD_NANOS) { + Thread.sleep((due - now - LEAD_NANOS) / 1_000_000L); + } + due += FRAME_NANOS; renderFrame(mix); line.write(toBytes(mix, line.channels()), 0, mix.length * 2 * line.channels()); }