Replace RMS voice detection with TeamSpeak's own RNN VAD

Ports WebRTC's rnn_vad (as TS3 embeds it) to Java: LPC, pitch
estimation, spectral features and the RNN itself, feeding a
speech-probability detector that replaces the old SpeechDetector.
Also switches the volume-gate threshold from raw dBFS to
InputLevel's scale, matching TS3's own slider and range, with a
migration for settings saved under the old key.
This commit is contained in:
2026-08-17 07:51:51 +00:00
parent 752676e863
commit e228dd4ad0
23 changed files with 2543 additions and 246 deletions

View File

@@ -0,0 +1,181 @@
package com.ts3client.ui;
import com.ts3client.audio.VoiceInput;
import com.ts3client.audio.desktop.AudioDevices;
import com.ts3client.audio.desktop.AudioPlayback;
import com.ts3client.audio.desktop.DesktopVoiceInput;
import com.ts3client.config.Settings;
import javax.swing.SwingUtilities;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.function.Consumer;
/**
* Drives the settings dialog's microphone test from a real capture chain.
*
* <p>The dialog runs its own {@link DesktopVoiceInput} rather than borrowing the connected
* one, whose listeners belong to the connection. Because it is the same class that feeds
* the server, the level and the gate shown here are exactly what would be transmitted —
* pre-processing, voice detection, hangover and pre-roll included.
*
* <p>Loopback is optional: when enabled, the frames that would be sent are played back
* locally so you can hear precisely what the other side would.
*/
final class MicrophoneTest {
/** Frames buffered for loopback before the oldest is dropped (~100 ms). */
private static final int LOOPBACK_QUEUE_FRAMES = 5;
private final Consumer<Double> onLevel;
private final Consumer<Boolean> onTransmitting;
private DesktopVoiceInput mic;
private final ArrayBlockingQueue<byte[]> loopbackQueue =
new ArrayBlockingQueue<>(LOOPBACK_QUEUE_FRAMES);
private volatile boolean loopbackEnabled;
private volatile boolean loopbackRunning;
private Thread loopbackThread;
private String outputDevice = "";
MicrophoneTest(Consumer<Double> onLevel, Consumer<Boolean> onTransmitting) {
this.onLevel = onLevel;
this.onTransmitting = onTransmitting;
}
/** Whether a capture chain is currently running. */
boolean isRunning() {
return mic != null;
}
/**
* (Re)starts the test chain against {@code settings}' devices and voice options.
*
* @return false if the capture device could not be opened
*/
boolean start(Settings settings) {
stop();
DesktopVoiceInput input = new DesktopVoiceInput(settings);
input.setLevelListener(db -> SwingUtilities.invokeLater(() -> onLevel.accept(db)));
input.setTalkListener(talking -> SwingUtilities.invokeLater(() -> onTransmitting.accept(talking)));
input.setMonitorListener(this::enqueueForLoopback);
this.outputDevice = settings.outputDevice;
try {
input.start();
} catch (RuntimeException e) {
return false; // Device busy or gone; leave the test switched off.
}
this.mic = input;
return true;
}
/**
* Stops the capture chain. The caller owns the UI reset: doing it here would race with
* a restart, whose own state has already been put on screen.
*/
void stop() {
setLoopback(false);
if (mic != null) {
mic.stop();
mic = null;
}
}
/** Applies a change to the test chain, if it is running. */
void configure(Consumer<VoiceInput> change) {
VoiceInput m = mic;
if (m != null) change.accept(m);
}
/** The playback device to loop back through; takes effect on the next enable. */
void setOutputDevice(String deviceId) {
this.outputDevice = deviceId == null ? "" : deviceId;
}
void setLoopback(boolean enabled) {
if (enabled == loopbackEnabled) {
return;
}
loopbackEnabled = enabled;
if (enabled) {
loopbackRunning = true;
loopbackThread = new Thread(this::loopbackLoop, "settings-loopback");
loopbackThread.setDaemon(true);
loopbackThread.start();
} else {
loopbackRunning = false;
if (loopbackThread != null) {
loopbackThread.interrupt();
loopbackThread = null;
}
loopbackQueue.clear();
}
}
/**
* Converts a transmitted frame to 16-bit PCM and queues it. Runs on the capture thread,
* so it must not block: a full queue means playback has fallen behind and the oldest
* frame is dropped instead.
*/
private void enqueueForLoopback(float[] interleaved, int channels) {
if (!loopbackEnabled) {
return;
}
byte[] pcm = toPcm16(interleaved);
if (!loopbackQueue.offer(pcm)) {
loopbackQueue.poll();
loopbackQueue.offer(pcm);
}
}
private void loopbackLoop() {
AudioPlayback line = null;
try {
// The monitored frames are mono for voice; ask for a matching line so no
// channel juggling is needed, and up-mix only if the device insists on stereo.
line = AudioDevices.openPlayback(outputDevice, 1);
line.start();
int channels = line.channels();
while (loopbackRunning) {
byte[] frame = loopbackQueue.poll(100, java.util.concurrent.TimeUnit.MILLISECONDS);
if (frame == null) {
continue;
}
byte[] out = (channels == 1) ? frame : upmix(frame, channels);
line.write(out, 0, out.length);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception ignored) {
// The device may be busy or gone; the test simply runs without loopback.
} finally {
if (line != null) line.close();
}
}
/** Converts float samples in [-1, 1] to 16-bit little-endian PCM. */
static byte[] toPcm16(float[] samples) {
byte[] pcm = new byte[samples.length * 2];
for (int i = 0; i < samples.length; i++) {
float f = samples[i];
if (f > 1f) f = 1f;
else if (f < -1f) f = -1f;
int s = Math.round(f * 32767f);
pcm[2 * i] = (byte) (s & 0xFF);
pcm[2 * i + 1] = (byte) ((s >> 8) & 0xFF);
}
return pcm;
}
/** Copies a mono frame across {@code channels} interleaved channels. */
static byte[] upmix(byte[] mono, int channels) {
byte[] out = new byte[mono.length * channels];
for (int i = 0, frames = mono.length / 2; i < frames; i++) {
for (int c = 0; c < channels; c++) {
out[2 * (i * channels + c)] = mono[2 * i];
out[2 * (i * channels + c) + 1] = mono[2 * i + 1];
}
}
return out;
}
}