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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user