Play notification sounds from core
The sound player was a desktop class only because it decoded wave files through Java Sound. A small RIFF reader replaces that, so the player moves to core and every platform gets it from AudioBackend by default. The reader takes integer PCM up to 32 bits and 32-bit float, in plain or extensible headers. It decodes all 151 files of the TS3 sound packs to the same samples Java Sound does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,8 +5,6 @@ import com.ts3client.audio.AudioIo;
|
||||
import com.ts3client.audio.StreamingVoiceOutput;
|
||||
import com.ts3client.audio.desktop.pipewire.PipeWire;
|
||||
import com.ts3client.audio.opus.OpusCodec;
|
||||
import com.ts3client.config.Settings;
|
||||
import com.ts3client.sound.SoundPlayer;
|
||||
|
||||
/**
|
||||
* Desktop audio backend: PipeWire for capture/playback where it is running, Java Sound
|
||||
@@ -32,11 +30,6 @@ public final class DesktopAudioBackend implements AudioBackend {
|
||||
return StreamingVoiceOutput.Lines.PER_SPEAKER;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SoundPlayer createSoundPlayer(Settings settings) {
|
||||
return new WavSoundPlayer(io(), settings.outputDevice);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return codec() + ", " + audioSystem();
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
package com.ts3client.audio.desktop;
|
||||
|
||||
import com.ts3client.audio.AudioIo;
|
||||
import com.ts3client.audio.AudioPlayback;
|
||||
import com.ts3client.audio.VoiceFormat;
|
||||
import com.ts3client.sound.SoundPlayer;
|
||||
|
||||
import javax.sound.sampled.AudioFormat;
|
||||
import javax.sound.sampled.AudioInputStream;
|
||||
import javax.sound.sampled.AudioSystem;
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Desktop {@link SoundPlayer}: plays sound-pack wave files on the configured
|
||||
* playback device.
|
||||
*
|
||||
* <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 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 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;
|
||||
private static final long FRAME_NANOS = VoiceFormat.FRAME_SIZE * 1_000_000_000L / VoiceFormat.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;
|
||||
|
||||
/** A decoded, 48 kHz mono sound being rendered. */
|
||||
private static final class Voice {
|
||||
final float[] pcm;
|
||||
final double volume;
|
||||
int position;
|
||||
|
||||
Voice(float[] pcm, double volume) {
|
||||
this.pcm = pcm;
|
||||
this.volume = volume;
|
||||
}
|
||||
}
|
||||
|
||||
private final Map<String, float[]> cache = new LinkedHashMap<>(16, 0.75f, true) {
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<String, float[]> eldest) {
|
||||
return size() > MAX_CACHED_FILES;
|
||||
}
|
||||
};
|
||||
|
||||
private final List<Voice> voices = new ArrayList<>();
|
||||
private final Object lock = new Object();
|
||||
|
||||
private final AudioIo io;
|
||||
private volatile String outputDevice;
|
||||
private volatile boolean running = true;
|
||||
private Thread mixer;
|
||||
|
||||
public WavSoundPlayer(AudioIo io, String outputDevice) {
|
||||
this.io = io;
|
||||
this.outputDevice = outputDevice;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setOutputDevice(String device) {
|
||||
this.outputDevice = device == null ? "" : device;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void play(File file, double volume) {
|
||||
if (file == null || !file.isFile() || volume <= 0) return;
|
||||
float[] pcm;
|
||||
try {
|
||||
pcm = decode(file);
|
||||
} catch (Exception e) {
|
||||
return; // unplayable file: stay silent rather than break the action
|
||||
}
|
||||
if (pcm.length == 0) return;
|
||||
|
||||
synchronized (lock) {
|
||||
if (!running) return;
|
||||
voices.add(new Voice(pcm, Math.min(1.0, volume)));
|
||||
if (mixer == null) {
|
||||
mixer = new Thread(this::mixLoop, "ts3j-sounds");
|
||||
mixer.setDaemon(true);
|
||||
mixer.start();
|
||||
}
|
||||
lock.notifyAll();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shutdown() {
|
||||
synchronized (lock) {
|
||||
running = false;
|
||||
voices.clear();
|
||||
lock.notifyAll();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- mixing ----
|
||||
|
||||
/**
|
||||
* 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>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[VoiceFormat.FRAME_SIZE];
|
||||
long due = 0; // when the next frame is due to leave the speaker
|
||||
while (awaitVoices()) {
|
||||
if (line == null) {
|
||||
line = io.openPlayback(outputDevice, VoiceFormat.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());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
synchronized (lock) {
|
||||
voices.clear();
|
||||
mixer = null;
|
||||
}
|
||||
} finally {
|
||||
if (line != null) line.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
synchronized (lock) {
|
||||
for (java.util.Iterator<Voice> it = voices.iterator(); it.hasNext(); ) {
|
||||
Voice v = it.next();
|
||||
int n = Math.min(mix.length, v.pcm.length - v.position);
|
||||
for (int i = 0; i < n; i++) {
|
||||
mix[i] += v.pcm[v.position + i] * v.volume;
|
||||
}
|
||||
v.position += n;
|
||||
if (v.position >= v.pcm.length) it.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Interleaves the mono mix across the line's channels as signed 16-bit LE. */
|
||||
private static byte[] toBytes(float[] mix, int channels) {
|
||||
byte[] out = new byte[mix.length * 2 * channels];
|
||||
for (int i = 0, k = 0; i < mix.length; i++) {
|
||||
float v = Math.max(-1f, Math.min(1f, mix[i]));
|
||||
short s = (short) Math.round(v * 32767.0);
|
||||
for (int c = 0; c < channels; c++, k += 2) {
|
||||
out[k] = (byte) (s & 0xFF);
|
||||
out[k + 1] = (byte) ((s >> 8) & 0xFF);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- decoding ----
|
||||
|
||||
private float[] decode(File file) throws Exception {
|
||||
String key = file.getAbsolutePath() + '@' + file.lastModified();
|
||||
synchronized (cache) {
|
||||
float[] cached = cache.get(key);
|
||||
if (cached != null) return cached;
|
||||
}
|
||||
float[] pcm = readMono48k(file);
|
||||
synchronized (cache) {
|
||||
cache.put(key, pcm);
|
||||
}
|
||||
return pcm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a sound file to mono 48 kHz float samples. Packs ship 44.1 kHz mono
|
||||
* waves, but nothing stops them from using another rate, depth or encoding, so
|
||||
* the conversion goes through Java Sound and a linear resample.
|
||||
*/
|
||||
private static float[] readMono48k(File file) throws Exception {
|
||||
try (AudioInputStream in = AudioSystem.getAudioInputStream(file)) {
|
||||
AudioFormat source = in.getFormat();
|
||||
int channels = Math.max(1, source.getChannels());
|
||||
AudioFormat pcmFormat = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED,
|
||||
source.getSampleRate(), 16, channels, channels * 2, source.getSampleRate(), false);
|
||||
try (AudioInputStream pcm = AudioSystem.getAudioInputStream(pcmFormat, in)) {
|
||||
byte[] data = pcm.readAllBytes();
|
||||
int frames = data.length / (2 * channels);
|
||||
float[] mono = new float[frames];
|
||||
for (int i = 0; i < frames; i++) {
|
||||
float sum = 0;
|
||||
for (int c = 0; c < channels; c++) {
|
||||
int k = 2 * (i * channels + c);
|
||||
sum += (short) ((data[k + 1] << 8) | (data[k] & 0xFF)) / 32768f;
|
||||
}
|
||||
mono[i] = sum / channels;
|
||||
}
|
||||
return resample(mono, source.getSampleRate(), VoiceFormat.SAMPLE_RATE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static float[] resample(float[] input, float fromRate, int toRate) {
|
||||
if (fromRate <= 0 || Math.abs(fromRate - toRate) < 0.5f || input.length == 0) return input;
|
||||
double ratio = toRate / (double) fromRate;
|
||||
int length = (int) (input.length * ratio);
|
||||
float[] out = new float[length];
|
||||
for (int i = 0; i < length; i++) {
|
||||
double position = i / ratio;
|
||||
int index = (int) position;
|
||||
double fraction = position - index;
|
||||
float a = input[Math.min(index, input.length - 1)];
|
||||
float b = input[Math.min(index + 1, input.length - 1)];
|
||||
out[i] = (float) (a + (b - a) * fraction);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user