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:
@@ -8,21 +8,28 @@ import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
|
||||
/**
|
||||
* Horizontal audio level meter (dBFS) with an optional VAD threshold marker.
|
||||
* The filled portion turns green once the level crosses the threshold, giving
|
||||
* immediate visual feedback while tuning voice activation.
|
||||
* Horizontal audio level meter with an optional VAD threshold marker. The filled portion
|
||||
* turns green once the level crosses the threshold, giving immediate visual feedback while
|
||||
* tuning voice activation.
|
||||
*
|
||||
* <p>Scaled to {@link com.ts3client.audio.InputLevel}, matching the TS3 client's slider.
|
||||
*/
|
||||
public final class LevelMeter extends JComponent {
|
||||
|
||||
private static final double MIN_DB = -70.0;
|
||||
private static final double MAX_DB = 0.0;
|
||||
private static final double MIN_DB = com.ts3client.audio.InputLevel.MIN_DB;
|
||||
private static final double MAX_DB = com.ts3client.audio.InputLevel.MAX_DB;
|
||||
|
||||
private volatile double levelDb = MIN_DB;
|
||||
private volatile double thresholdDb = -45.0;
|
||||
private volatile double thresholdDb = -40.0;
|
||||
private volatile boolean showThreshold = true;
|
||||
private volatile boolean transmitting;
|
||||
|
||||
public LevelMeter() {
|
||||
setPreferredSize(new Dimension(240, 18));
|
||||
// A bare JComponent reports no minimum of its own, so a layout tight on space
|
||||
// collapses the bar to nothing. Keep the height and let only the width give.
|
||||
setMinimumSize(new Dimension(60, 18));
|
||||
setMaximumSize(new Dimension(Integer.MAX_VALUE, 18));
|
||||
}
|
||||
|
||||
public void setLevel(double db) {
|
||||
@@ -40,6 +47,16 @@ public final class LevelMeter extends JComponent {
|
||||
repaint();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the gate is currently open. This is the real transmit decision, which in the
|
||||
* Automatic and Hybrid modes depends on the speech detector and the hangover as well as
|
||||
* on the level, so the bar cannot infer it from the threshold alone.
|
||||
*/
|
||||
public void setTransmitting(boolean transmitting) {
|
||||
this.transmitting = transmitting;
|
||||
repaint();
|
||||
}
|
||||
|
||||
private int dbToX(double db, int w) {
|
||||
double clamped = Math.max(MIN_DB, Math.min(MAX_DB, db));
|
||||
return (int) ((clamped - MIN_DB) / (MAX_DB - MIN_DB) * w);
|
||||
@@ -56,8 +73,7 @@ public final class LevelMeter extends JComponent {
|
||||
g.fillRoundRect(0, 0, w - 1, h - 1, 6, 6);
|
||||
|
||||
int level = dbToX(levelDb, w - 2);
|
||||
boolean over = levelDb >= thresholdDb;
|
||||
g.setColor(over ? Theme.TALKING : new Color(0x5A9BD4));
|
||||
g.setColor(transmitting ? Theme.TALKING : new Color(0x5A9BD4));
|
||||
g.fillRoundRect(1, 1, Math.max(0, level), h - 3, 5, 5);
|
||||
|
||||
if (showThreshold) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.audio.InputLevel;
|
||||
import com.ts3client.audio.OpusParameters;
|
||||
import com.ts3client.audio.VoiceInput;
|
||||
import com.ts3client.audio.VoiceOutput;
|
||||
import com.ts3client.audio.desktop.AudioCapture;
|
||||
import com.ts3client.audio.desktop.AudioDevices;
|
||||
import com.ts3client.config.Settings;
|
||||
import com.ts3client.hotkey.Hotkey;
|
||||
@@ -23,6 +23,7 @@ import javax.swing.JPanel;
|
||||
import javax.swing.JRadioButton;
|
||||
import javax.swing.JSlider;
|
||||
import javax.swing.JTabbedPane;
|
||||
import javax.swing.JToggleButton;
|
||||
import javax.swing.Scrollable;
|
||||
import javax.swing.SwingUtilities;
|
||||
import java.awt.BorderLayout;
|
||||
@@ -80,6 +81,9 @@ public final class SettingsDialog extends JDialog {
|
||||
private JLabel thresholdLabel;
|
||||
private JLabel speechLabel;
|
||||
private LevelMeter meter;
|
||||
private JToggleButton testButton;
|
||||
private JCheckBox loopbackCheck;
|
||||
private JLabel talkIndicator;
|
||||
private JButton pttKeyButton;
|
||||
private JSlider bitrateSlider;
|
||||
private JLabel bitrateLabel;
|
||||
@@ -88,8 +92,7 @@ public final class SettingsDialog extends JDialog {
|
||||
private JCheckBox fecCheck;
|
||||
private JCheckBox musicCheck;
|
||||
|
||||
private volatile boolean meterRunning;
|
||||
private Thread meterThread;
|
||||
private final MicrophoneTest micTest = new MicrophoneTest(this::onTestLevel, this::onTestTalking);
|
||||
|
||||
public SettingsDialog(Frame owner, Settings settings,
|
||||
VoiceInput liveMic, VoiceOutput livePlayback,
|
||||
@@ -136,7 +139,7 @@ public final class SettingsDialog extends JDialog {
|
||||
addWindowListener(new java.awt.event.WindowAdapter() {
|
||||
@Override
|
||||
public void windowClosed(java.awt.event.WindowEvent e) {
|
||||
stopMeter();
|
||||
micTest.stop();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -144,7 +147,6 @@ public final class SettingsDialog extends JDialog {
|
||||
setSize(new Dimension(480, 540));
|
||||
setMinimumSize(new Dimension(420, 360));
|
||||
setLocationRelativeTo(owner);
|
||||
startMeter();
|
||||
}
|
||||
|
||||
private JPanel buildDevicesTab() {
|
||||
@@ -181,10 +183,10 @@ public final class SettingsDialog extends JDialog {
|
||||
outputVol.addChangeListener(e -> {
|
||||
if (livePlayback != null) livePlayback.setMasterVolume(outputVol.getValue() / 100.0);
|
||||
});
|
||||
inputGain.addChangeListener(e -> {
|
||||
if (liveMic != null) liveMic.setInputGain(inputGain.getValue() / 100.0);
|
||||
});
|
||||
inputCombo.addActionListener(e -> restartMeter());
|
||||
inputGain.addChangeListener(e ->
|
||||
applyLive(m -> m.setInputGain(inputGain.getValue() / 100.0)));
|
||||
inputCombo.addActionListener(e -> restartTest());
|
||||
outputCombo.addActionListener(e -> micTest.setOutputDevice(comboValue(outputCombo)));
|
||||
|
||||
c.gridx = 0;
|
||||
c.gridy = row++;
|
||||
@@ -223,19 +225,18 @@ public final class SettingsDialog extends JDialog {
|
||||
|
||||
Runnable syncNoise = () -> {
|
||||
denoiseLevel.setEnabled(denoiseCheck.isSelected());
|
||||
if (liveMic != null) {
|
||||
liveMic.setNoiseSuppression(denoiseCheck.isSelected());
|
||||
liveMic.setDenoiserLevel(denoiseLevel.getValue() / 100.0);
|
||||
liveMic.setTypingAttenuation(typingCheck.isSelected());
|
||||
liveMic.setAgc(agcCheck.isSelected());
|
||||
}
|
||||
applyLive(m -> {
|
||||
m.setNoiseSuppression(denoiseCheck.isSelected());
|
||||
m.setDenoiserLevel(denoiseLevel.getValue() / 100.0);
|
||||
m.setTypingAttenuation(typingCheck.isSelected());
|
||||
m.setAgc(agcCheck.isSelected());
|
||||
});
|
||||
};
|
||||
denoiseCheck.addActionListener(e -> syncNoise.run());
|
||||
typingCheck.addActionListener(e -> syncNoise.run());
|
||||
agcCheck.addActionListener(e -> syncNoise.run());
|
||||
denoiseLevel.addChangeListener(e -> {
|
||||
if (liveMic != null) liveMic.setDenoiserLevel(denoiseLevel.getValue() / 100.0);
|
||||
});
|
||||
denoiseLevel.addChangeListener(e ->
|
||||
applyLive(m -> m.setDenoiserLevel(denoiseLevel.getValue() / 100.0)));
|
||||
syncNoise.run();
|
||||
|
||||
// filler
|
||||
@@ -285,26 +286,30 @@ public final class SettingsDialog extends JDialog {
|
||||
c.gridy = row;
|
||||
c.gridwidth = 2;
|
||||
c.insets = new Insets(10, 4, 2, 4);
|
||||
p.add(new JLabel("Input level (speak to test):"), c);
|
||||
p.add(new JLabel("Input level:"), c);
|
||||
c.gridy = ++row;
|
||||
p.add(meter, c);
|
||||
c.insets = new Insets(4, 4, 4, 4);
|
||||
c.gridy = ++row;
|
||||
p.add(buildTestControls(), c);
|
||||
c.gridwidth = 1;
|
||||
row++;
|
||||
|
||||
vadModeCombo = new JComboBox<>(new String[]{"Automatic", "Volume Gate", "Hybrid"});
|
||||
vadModeCombo.setSelectedIndex(vadModeIndex(settings.vadMode));
|
||||
vadModeCombo.addActionListener(e -> micTest.configure(m -> m.setVadMode(currentVadMode())));
|
||||
vadModeCombo.setToolTipText("<html><b>Automatic</b>: intelligent speech detection.<br>"
|
||||
+ "<b>Volume Gate</b>: transmit when loud enough.<br>"
|
||||
+ "<b>Hybrid</b>: loud enough <i>and</i> detected as speech.</html>");
|
||||
addRow(p, c, row++, new JLabel("Detection:"), vadModeCombo);
|
||||
|
||||
thresholdSlider = new JSlider(-70, 0, (int) Math.round(settings.vadThresholdDb));
|
||||
thresholdSlider = new JSlider((int) InputLevel.MIN_DB, (int) InputLevel.MAX_DB,
|
||||
(int) Math.round(settings.vadThresholdDb));
|
||||
thresholdLabel = new JLabel(Math.round(settings.vadThresholdDb) + " dB");
|
||||
thresholdSlider.addChangeListener(e -> {
|
||||
meter.setThreshold(thresholdSlider.getValue());
|
||||
thresholdLabel.setText(thresholdSlider.getValue() + " dB");
|
||||
if (liveMic != null) liveMic.setThresholdDb(thresholdSlider.getValue());
|
||||
applyLive(m -> m.setThresholdDb(thresholdSlider.getValue()));
|
||||
});
|
||||
addRow(p, c, row++, new JLabel("Volume gate:"), sliderWithLabel(thresholdSlider, thresholdLabel));
|
||||
|
||||
@@ -312,7 +317,7 @@ public final class SettingsDialog extends JDialog {
|
||||
speechLabel = new JLabel(Math.round(settings.speechThreshold * 100) + "%");
|
||||
speechSlider.addChangeListener(e -> {
|
||||
speechLabel.setText(speechSlider.getValue() + "%");
|
||||
if (liveMic != null) liveMic.setSpeechThreshold(speechSlider.getValue() / 100.0);
|
||||
applyLive(m -> m.setSpeechThreshold(speechSlider.getValue() / 100.0));
|
||||
});
|
||||
addRow(p, c, row++, new JLabel("Speech threshold:"), sliderWithLabel(speechSlider, speechLabel));
|
||||
|
||||
@@ -322,6 +327,8 @@ public final class SettingsDialog extends JDialog {
|
||||
addRow(p, c, row++, new JLabel("Push-to-talk hotkey:"), pttKeyButton);
|
||||
|
||||
vadOverPttCheck = new JCheckBox("Also detect voice while push-to-talk", settings.vadOverPtt);
|
||||
vadOverPttCheck.addActionListener(e ->
|
||||
micTest.configure(m -> m.setVadOverPtt(vadOverPttCheck.isSelected())));
|
||||
c.gridx = 0;
|
||||
c.gridy = row++;
|
||||
c.gridwidth = 2;
|
||||
@@ -542,46 +549,67 @@ public final class SettingsDialog extends JDialog {
|
||||
hotkeysPanel.reload();
|
||||
}
|
||||
|
||||
private void apply() {
|
||||
settings.inputDevice = comboValue(inputCombo);
|
||||
settings.outputDevice = comboValue(outputCombo);
|
||||
settings.inputVolume = inputGain.getValue() / 100.0;
|
||||
settings.outputVolume = outputVol.getValue() / 100.0;
|
||||
settings.denoise = denoiseCheck.isSelected();
|
||||
settings.denoiserLevel = denoiseLevel.getValue() / 100.0;
|
||||
settings.typingAttenuation = typingCheck.isSelected();
|
||||
settings.agc = agcCheck.isSelected();
|
||||
settings.inputMode = pttRadio.isSelected() ? Settings.InputMode.PUSH_TO_TALK
|
||||
/** Copies the audio form into {@code target}, without touching anything else. */
|
||||
private void writeAudioSettings(Settings target) {
|
||||
target.inputDevice = comboValue(inputCombo);
|
||||
target.outputDevice = comboValue(outputCombo);
|
||||
target.inputVolume = inputGain.getValue() / 100.0;
|
||||
target.outputVolume = outputVol.getValue() / 100.0;
|
||||
target.denoise = denoiseCheck.isSelected();
|
||||
target.denoiserLevel = denoiseLevel.getValue() / 100.0;
|
||||
target.typingAttenuation = typingCheck.isSelected();
|
||||
target.agc = agcCheck.isSelected();
|
||||
target.inputMode = pttRadio.isSelected() ? Settings.InputMode.PUSH_TO_TALK
|
||||
: contRadio.isSelected() ? Settings.InputMode.CONTINUOUS
|
||||
: Settings.InputMode.VOICE_ACTIVATION;
|
||||
settings.vadMode = currentVadMode();
|
||||
settings.vadThresholdDb = thresholdSlider.getValue();
|
||||
settings.speechThreshold = speechSlider.getValue() / 100.0;
|
||||
settings.vadOverPtt = vadOverPttCheck.isSelected();
|
||||
settings.bitrate = bitrateSlider.getValue() * 1000;
|
||||
settings.complexity = complexitySlider.getValue();
|
||||
settings.vbr = vbrCheck.isSelected();
|
||||
settings.fec = fecCheck.isSelected();
|
||||
settings.music = musicCheck.isSelected();
|
||||
target.vadMode = currentVadMode();
|
||||
target.vadThresholdDb = thresholdSlider.getValue();
|
||||
target.speechThreshold = speechSlider.getValue() / 100.0;
|
||||
target.vadOverPtt = vadOverPttCheck.isSelected();
|
||||
target.bitrate = bitrateSlider.getValue() * 1000;
|
||||
target.complexity = complexitySlider.getValue();
|
||||
target.vbr = vbrCheck.isSelected();
|
||||
target.fec = fecCheck.isSelected();
|
||||
target.music = musicCheck.isSelected();
|
||||
}
|
||||
|
||||
/**
|
||||
* The audio form as a standalone {@link Settings}, so the test chain runs with the
|
||||
* values currently on screen rather than the ones last saved.
|
||||
*
|
||||
* <p>The test always runs voice activation: it exists to tune the gate, and push-to-talk
|
||||
* would need the global hotkey, which belongs to the connected microphone.
|
||||
*/
|
||||
private Settings audioSnapshot() {
|
||||
Settings snapshot = new Settings();
|
||||
writeAudioSettings(snapshot);
|
||||
if (snapshot.inputMode == Settings.InputMode.PUSH_TO_TALK) {
|
||||
snapshot.inputMode = Settings.InputMode.VOICE_ACTIVATION;
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private void apply() {
|
||||
writeAudioSettings(settings);
|
||||
notificationsPanel.apply();
|
||||
iconPackPanel.apply();
|
||||
clientVersionPanel.apply();
|
||||
hotkeysPanel.apply();
|
||||
settings.save();
|
||||
|
||||
if (liveMic != null) {
|
||||
liveMic.setMode(settings.inputMode);
|
||||
liveMic.setVadMode(settings.vadMode);
|
||||
liveMic.setThresholdDb(settings.vadThresholdDb);
|
||||
liveMic.setSpeechThreshold(settings.speechThreshold);
|
||||
liveMic.setVadOverPtt(settings.vadOverPtt);
|
||||
liveMic.setInputGain(settings.inputVolume);
|
||||
liveMic.setNoiseSuppression(settings.denoise);
|
||||
liveMic.setDenoiserLevel(settings.denoiserLevel);
|
||||
liveMic.setTypingAttenuation(settings.typingAttenuation);
|
||||
liveMic.setAgc(settings.agc);
|
||||
liveMic.setOpusParameters(OpusParameters.from(settings));
|
||||
}
|
||||
applyLive(m -> {
|
||||
m.setMode(settings.inputMode);
|
||||
m.setVadMode(settings.vadMode);
|
||||
m.setThresholdDb(settings.vadThresholdDb);
|
||||
m.setSpeechThreshold(settings.speechThreshold);
|
||||
m.setVadOverPtt(settings.vadOverPtt);
|
||||
m.setInputGain(settings.inputVolume);
|
||||
m.setNoiseSuppression(settings.denoise);
|
||||
m.setDenoiserLevel(settings.denoiserLevel);
|
||||
m.setTypingAttenuation(settings.typingAttenuation);
|
||||
m.setAgc(settings.agc);
|
||||
m.setOpusParameters(OpusParameters.from(settings));
|
||||
});
|
||||
if (livePlayback != null) {
|
||||
livePlayback.setMasterVolume(settings.outputVolume);
|
||||
livePlayback.setOutputDevice(settings.outputDevice);
|
||||
@@ -597,64 +625,91 @@ public final class SettingsDialog extends JDialog {
|
||||
}
|
||||
|
||||
private void close() {
|
||||
stopMeter();
|
||||
micTest.stop();
|
||||
dispose();
|
||||
}
|
||||
|
||||
// ---- live meter ----
|
||||
// ---- microphone test ----
|
||||
|
||||
private void startMeter() {
|
||||
meterRunning = true;
|
||||
meterThread = new Thread(this::meterLoop, "settings-meter");
|
||||
meterThread.setDaemon(true);
|
||||
meterThread.start();
|
||||
/**
|
||||
* The test row: a toggle that runs the capture chain, an indicator showing whether the
|
||||
* gate is open, and an optional loopback so you can hear what is being sent.
|
||||
*/
|
||||
private JPanel buildTestControls() {
|
||||
JPanel row = new JPanel(new java.awt.FlowLayout(java.awt.FlowLayout.LEFT, 8, 0));
|
||||
|
||||
testButton = new JToggleButton("Begin Test");
|
||||
testButton.setToolTipText("Run the capture chain exactly as it runs while connected, "
|
||||
+ "so the bar and the indicator show what would actually be transmitted.");
|
||||
testButton.addActionListener(e -> setTesting(testButton.isSelected()));
|
||||
|
||||
loopbackCheck = new JCheckBox("Hear myself");
|
||||
loopbackCheck.setToolTipText("Play the transmitted audio back through the playback "
|
||||
+ "device. Use headphones to avoid feedback.");
|
||||
loopbackCheck.setEnabled(false);
|
||||
loopbackCheck.addActionListener(e -> micTest.setLoopback(loopbackCheck.isSelected()));
|
||||
|
||||
talkIndicator = new JLabel("Not transmitting", Icons.clientIdle(), JLabel.LEFT);
|
||||
talkIndicator.setToolTipText("Lights up while your microphone is open, exactly as "
|
||||
+ "other users would see you in the channel list.");
|
||||
|
||||
row.add(testButton);
|
||||
row.add(loopbackCheck);
|
||||
row.add(talkIndicator);
|
||||
return row;
|
||||
}
|
||||
|
||||
private void restartMeter() {
|
||||
stopMeter();
|
||||
startMeter();
|
||||
private void setTesting(boolean on) {
|
||||
if (on && !micTest.start(audioSnapshot())) {
|
||||
testButton.setSelected(false);
|
||||
testButton.setText("Begin Test");
|
||||
loopbackCheck.setEnabled(false);
|
||||
resetTestIndicators();
|
||||
talkIndicator.setText("Capture device unavailable");
|
||||
return;
|
||||
}
|
||||
if (!on) {
|
||||
micTest.stop();
|
||||
loopbackCheck.setSelected(false);
|
||||
resetTestIndicators();
|
||||
}
|
||||
testButton.setText(on ? "Stop Test" : "Begin Test");
|
||||
loopbackCheck.setEnabled(on);
|
||||
}
|
||||
|
||||
private void stopMeter() {
|
||||
meterRunning = false;
|
||||
if (meterThread != null) {
|
||||
meterThread.interrupt();
|
||||
meterThread = null;
|
||||
private void resetTestIndicators() {
|
||||
onTestTalking(false);
|
||||
onTestLevel(InputLevel.SILENCE_DB);
|
||||
}
|
||||
|
||||
/** Restarts the test chain, if running, so a device change takes effect. */
|
||||
private void restartTest() {
|
||||
if (micTest.isRunning()) {
|
||||
boolean loopback = loopbackCheck.isSelected();
|
||||
if (micTest.start(audioSnapshot())) {
|
||||
micTest.setLoopback(loopback);
|
||||
} else {
|
||||
setTesting(false);
|
||||
testButton.setSelected(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void meterLoop() {
|
||||
String device = comboValue(inputCombo);
|
||||
AudioCapture line = null;
|
||||
try {
|
||||
line = AudioDevices.openCapture(device, AudioDevices.MAX_CHANNELS);
|
||||
line.start();
|
||||
int frame = AudioDevices.FRAME_SIZE;
|
||||
int channels = line.channels();
|
||||
byte[] buf = new byte[frame * 2 * channels];
|
||||
double gain = (inputGain != null ? inputGain.getValue() / 100.0 : 1.0);
|
||||
while (meterRunning) {
|
||||
if (line.read(buf, 0, buf.length) < buf.length) break;
|
||||
// Metering follows the capture chain: level is taken off the downmix.
|
||||
double sumSq = 0;
|
||||
for (int i = 0; i < frame; i++) {
|
||||
double mono = 0;
|
||||
for (int c = 0; c < channels; c++) {
|
||||
int k = i * channels + c;
|
||||
short s = (short) ((buf[2 * k + 1] << 8) | (buf[2 * k] & 0xFF));
|
||||
mono += s / 32768.0;
|
||||
}
|
||||
double f = mono / channels * gain;
|
||||
sumSq += f * f;
|
||||
}
|
||||
double rms = Math.sqrt(sumSq / frame);
|
||||
double db = rms <= 1e-9 ? -100 : 20 * Math.log10(rms);
|
||||
final double fdb = db;
|
||||
if (meter != null) SwingUtilities.invokeLater(() -> meter.setLevel(fdb));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
} finally {
|
||||
if (line != null) line.close();
|
||||
/** Applies a live change to the connected microphone and to the test one alike. */
|
||||
private void applyLive(java.util.function.Consumer<VoiceInput> change) {
|
||||
if (liveMic != null) change.accept(liveMic);
|
||||
micTest.configure(change);
|
||||
}
|
||||
|
||||
private void onTestLevel(double db) {
|
||||
if (meter != null) meter.setLevel(db);
|
||||
}
|
||||
|
||||
private void onTestTalking(boolean talking) {
|
||||
if (meter != null) meter.setTransmitting(talking);
|
||||
if (talkIndicator != null) {
|
||||
talkIndicator.setIcon(talking ? Icons.clientTalking() : Icons.clientIdle());
|
||||
talkIndicator.setText(talking ? "Transmitting" : "Not transmitting");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user