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 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 11:56:27 +00:00
parent 2d7b82f9a3
commit e1f62ab50d

View File

@@ -16,14 +16,18 @@ import java.util.Map;
* playback device. * playback device.
* *
* <p>All notification sounds share one playback line and are mixed together, so * <p>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 * two events firing at once never fight over the device, and the line is dropped
* while something is actually playing. Files are decoded once, resampled to the * again after a long enough quiet spell. Files are decoded once, resampled to the
* device's 48&nbsp;kHz and cached, so repeat events cost nothing but the mix. * device's 48&nbsp;kHz and cached, so repeat events cost nothing but the mix.
*/ */
public final class WavSoundPlayer implements SoundPlayer { 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. */ /** Sound files are small; this caps the decoded cache anyway. */
private static final int MAX_CACHED_FILES = 64; private static final int MAX_CACHED_FILES = 64;
@@ -97,31 +101,21 @@ public final class WavSoundPlayer implements SoundPlayer {
// ---- mixing ---- // ---- mixing ----
/** /**
* Renders the active sounds onto one line until everything has been quiet for * Renders the active sounds onto one line, idling (rather than writing silence)
* {@link #IDLE_FRAMES}, then gives the device back. * between them and giving the device back after {@link #IDLE_KEEP_OPEN_NANOS}.
*
* <p>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() { private void mixLoop() {
AudioPlayback line = null; AudioPlayback line = null;
try { try {
int idle = 0;
float[] mix = new float[AudioDevices.FRAME_SIZE]; float[] mix = new float[AudioDevices.FRAME_SIZE];
while (true) { while (awaitVoices()) {
synchronized (lock) {
if (!running) return;
if (voices.isEmpty()) {
if (++idle > IDLE_FRAMES) {
mixer = null;
return;
}
} else {
idle = 0;
}
}
if (line == null) { if (line == null) {
line = AudioDevices.openPlayback(outputDevice, AudioDevices.MAX_CHANNELS); line = AudioDevices.openPlayback(outputDevice, AudioDevices.MAX_CHANNELS);
line.start(); line.start();
} }
// Silence keeps the line running (and paces this loop) until it is closed.
renderFrame(mix); renderFrame(mix);
line.write(toBytes(mix, line.channels()), 0, mix.length * 2 * line.channels()); 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. */ /** Sums the active sounds into {@code mix}, dropping the ones that ran out. */
private void renderFrame(float[] mix) { private void renderFrame(float[] mix) {
java.util.Arrays.fill(mix, 0f); java.util.Arrays.fill(mix, 0f);