Start overlapping notification sounds without delay

The sound mixer rendered as fast as the playback line accepted, running a
full device buffer ahead of real time, so a sound fired while another
played could only join the mix behind all of that queued audio, and each
further one landed later still. Pace the mixer against the clock so it
never commits more than a few frames ahead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 22:54:02 +00:00
parent ad62f5fc92
commit 636090d075

View File

@@ -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}.
*
* <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.
* <p>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());
}