Split SettingsDialog into focused tab panels
Extract the Playback/Capture and Voice Activation tabs (device pickers, gain/volume sliders, noise reduction, VAD/PTT tuning, microphone test) into DevicesPanel and VoiceActivationPanel, alongside the existing NotificationsPanel/HotkeysPanel/IconPackPanel/ClientVersionPanel. A new FormPanel base holds the shared GridBagLayout scaffolding. SettingsDialog is now a thin coordinator (753 -> 187 lines); its public API and all settings read/write behavior are unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,160 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.audio.VoiceInput;
|
||||||
|
import com.ts3client.audio.VoiceOutput;
|
||||||
|
import com.ts3client.audio.desktop.AudioDevices;
|
||||||
|
import com.ts3client.config.Settings;
|
||||||
|
|
||||||
|
import javax.swing.Box;
|
||||||
|
import javax.swing.JCheckBox;
|
||||||
|
import javax.swing.JComboBox;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JSlider;
|
||||||
|
import java.awt.GridBagConstraints;
|
||||||
|
import java.awt.Insets;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options dialog's "Playback / Capture" tab: device pickers, gain/volume, and the
|
||||||
|
* noise-reduction pre-processing options. Gain and pre-processing changes are pushed
|
||||||
|
* live via {@code applyLive}, which reaches both the connected microphone and the
|
||||||
|
* dialog's own microphone test.
|
||||||
|
*/
|
||||||
|
final class DevicesPanel extends FormPanel {
|
||||||
|
|
||||||
|
private final JComboBox<AudioDevices.Device> inputCombo;
|
||||||
|
private final JComboBox<AudioDevices.Device> outputCombo;
|
||||||
|
private final JSlider inputGain;
|
||||||
|
private final JSlider outputVol;
|
||||||
|
private final JCheckBox denoiseCheck;
|
||||||
|
private final JSlider denoiseLevel;
|
||||||
|
private final JCheckBox typingCheck;
|
||||||
|
private final JCheckBox agcCheck;
|
||||||
|
|
||||||
|
DevicesPanel(Settings settings, VoiceOutput livePlayback,
|
||||||
|
Consumer<Consumer<VoiceInput>> applyLive,
|
||||||
|
Runnable onInputDeviceChanged, Consumer<String> onOutputDeviceChanged) {
|
||||||
|
GridBagConstraints c = gbc();
|
||||||
|
|
||||||
|
List<AudioDevices.Device> ins = AudioDevices.inputDevices();
|
||||||
|
List<AudioDevices.Device> outs = AudioDevices.outputDevices();
|
||||||
|
|
||||||
|
inputCombo = new JComboBox<>(ins.toArray(new AudioDevices.Device[0]));
|
||||||
|
outputCombo = new JComboBox<>(outs.toArray(new AudioDevices.Device[0]));
|
||||||
|
selectOrDefault(inputCombo, settings.inputDevice);
|
||||||
|
selectOrDefault(outputCombo, settings.outputDevice);
|
||||||
|
|
||||||
|
String deviceHint = "<html>Named devices are PipeWire's, and are routed through it "
|
||||||
|
+ "(so per-application volume and rerouting keep working).<br>"
|
||||||
|
+ "<b>ALSA:</b> entries talk to the sound card directly, taking it exclusively.</html>";
|
||||||
|
inputCombo.setToolTipText(deviceHint);
|
||||||
|
outputCombo.setToolTipText(deviceHint);
|
||||||
|
limitWidth(inputCombo, FIELD_WIDTH);
|
||||||
|
limitWidth(outputCombo, FIELD_WIDTH);
|
||||||
|
|
||||||
|
int row = 0;
|
||||||
|
addRow(this, c, row++, new JLabel("Capture device (microphone):"), inputCombo);
|
||||||
|
addRow(this, c, row++, new JLabel("Playback device (speakers):"), outputCombo);
|
||||||
|
|
||||||
|
inputGain = new JSlider(0, 200, (int) Math.round(settings.inputVolume * 100));
|
||||||
|
outputVol = new JSlider(0, 200, (int) Math.round(settings.outputVolume * 100));
|
||||||
|
limitWidth(inputGain, SLIDER_WIDTH);
|
||||||
|
limitWidth(outputVol, SLIDER_WIDTH);
|
||||||
|
addRow(this, c, row++, new JLabel("Microphone gain:"), inputGain);
|
||||||
|
addRow(this, c, row++, new JLabel("Playback volume:"), outputVol);
|
||||||
|
|
||||||
|
outputVol.addChangeListener(e -> {
|
||||||
|
if (livePlayback != null) livePlayback.setMasterVolume(outputVol.getValue() / 100.0);
|
||||||
|
});
|
||||||
|
inputGain.addChangeListener(e ->
|
||||||
|
applyLive.accept(m -> m.setInputGain(inputGain.getValue() / 100.0)));
|
||||||
|
inputCombo.addActionListener(e -> onInputDeviceChanged.run());
|
||||||
|
outputCombo.addActionListener(e -> onOutputDeviceChanged.accept(comboValue(outputCombo)));
|
||||||
|
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = row++;
|
||||||
|
c.gridwidth = 2;
|
||||||
|
c.insets = new Insets(14, 4, 2, 4);
|
||||||
|
add(new JLabel("Noise reduction"), c);
|
||||||
|
c.insets = new Insets(4, 4, 4, 4);
|
||||||
|
c.gridwidth = 1;
|
||||||
|
|
||||||
|
denoiseCheck = new JCheckBox("Remove background noise", settings.denoise);
|
||||||
|
denoiseCheck.setToolTipText("Attempt to filter out background noises.");
|
||||||
|
denoiseLevel = new JSlider(0, 100, (int) Math.round(settings.denoiserLevel * 100));
|
||||||
|
limitWidth(denoiseLevel, SLIDER_WIDTH);
|
||||||
|
denoiseLevel.setToolTipText("Higher = more aggressive noise removal.");
|
||||||
|
typingCheck = new JCheckBox("Typing attenuation", settings.typingAttenuation);
|
||||||
|
typingCheck.setToolTipText("<html><b>Typing attenuation</b> tries to detect and "
|
||||||
|
+ "reduce the sounds made by typing.</html>");
|
||||||
|
agcCheck = new JCheckBox("Automatic gain control (AGC)", settings.agc);
|
||||||
|
agcCheck.setToolTipText("<html><b>Automatic gain control</b> normalises your "
|
||||||
|
+ "microphone loudness to a target level, boosting quiet mics and taming "
|
||||||
|
+ "loud ones.</html>");
|
||||||
|
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = row++;
|
||||||
|
c.gridwidth = 2;
|
||||||
|
add(denoiseCheck, c);
|
||||||
|
c.gridwidth = 1;
|
||||||
|
addRow(this, c, row++, new JLabel("Noise removal level:"), denoiseLevel);
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = row++;
|
||||||
|
c.gridwidth = 2;
|
||||||
|
add(typingCheck, c);
|
||||||
|
c.gridy = row++;
|
||||||
|
add(agcCheck, c);
|
||||||
|
c.gridwidth = 1;
|
||||||
|
|
||||||
|
Runnable syncNoise = () -> {
|
||||||
|
denoiseLevel.setEnabled(denoiseCheck.isSelected());
|
||||||
|
applyLive.accept(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 ->
|
||||||
|
applyLive.accept(m -> m.setDenoiserLevel(denoiseLevel.getValue() / 100.0)));
|
||||||
|
syncNoise.run();
|
||||||
|
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = row;
|
||||||
|
c.weighty = 1;
|
||||||
|
add(Box.createGlue(), c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies the form into {@code target}, without touching anything else. */
|
||||||
|
void writeInto(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();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void selectOrDefault(JComboBox<AudioDevices.Device> combo, String deviceId) {
|
||||||
|
if (deviceId != null && !deviceId.isEmpty()) {
|
||||||
|
for (int i = 0; i < combo.getItemCount(); i++) {
|
||||||
|
if (deviceId.equals(combo.getItemAt(i).id())) {
|
||||||
|
combo.setSelectedIndex(i);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
combo.setSelectedIndex(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String comboValue(JComboBox<AudioDevices.Device> combo) {
|
||||||
|
AudioDevices.Device d = (AudioDevices.Device) combo.getSelectedItem();
|
||||||
|
return d == null ? "" : d.id();
|
||||||
|
}
|
||||||
|
}
|
||||||
104
ts3-client/swing/src/main/java/com/ts3client/ui/FormPanel.java
Normal file
104
ts3-client/swing/src/main/java/com/ts3client/ui/FormPanel.java
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import javax.swing.BorderFactory;
|
||||||
|
import javax.swing.JComponent;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.JSlider;
|
||||||
|
import javax.swing.Scrollable;
|
||||||
|
import java.awt.BorderLayout;
|
||||||
|
import java.awt.Component;
|
||||||
|
import java.awt.Dimension;
|
||||||
|
import java.awt.GridBagConstraints;
|
||||||
|
import java.awt.GridBagLayout;
|
||||||
|
import java.awt.Insets;
|
||||||
|
import java.awt.Rectangle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base for a settings tab's field grid: follows the scroll pane's width instead of
|
||||||
|
* demanding its own preferred one, so rows stay inside the dialog instead of scrolling
|
||||||
|
* sideways. Also holds the small GridBagLayout helpers every such tab needs.
|
||||||
|
*/
|
||||||
|
class FormPanel extends JPanel implements Scrollable {
|
||||||
|
|
||||||
|
static final int FIELD_WIDTH = 240;
|
||||||
|
static final int SLIDER_WIDTH = 200;
|
||||||
|
static final int MIN_FIELD_WIDTH = 60;
|
||||||
|
|
||||||
|
FormPanel() {
|
||||||
|
super(new GridBagLayout());
|
||||||
|
setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Dimension getPreferredScrollableViewportSize() {
|
||||||
|
return getPreferredSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getScrollableUnitIncrement(Rectangle visible, int orientation, int direction) {
|
||||||
|
return 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getScrollableBlockIncrement(Rectangle visible, int orientation, int direction) {
|
||||||
|
return visible.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean getScrollableTracksViewportWidth() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean getScrollableTracksViewportHeight() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static GridBagConstraints gbc() {
|
||||||
|
GridBagConstraints c = new GridBagConstraints();
|
||||||
|
c.insets = new Insets(4, 4, 4, 4);
|
||||||
|
c.anchor = GridBagConstraints.WEST;
|
||||||
|
c.fill = GridBagConstraints.HORIZONTAL;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void addRow(JPanel p, GridBagConstraints c, int row, JLabel label, Component field) {
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = row;
|
||||||
|
c.weightx = 0;
|
||||||
|
c.gridwidth = 1;
|
||||||
|
p.add(label, c);
|
||||||
|
c.gridx = 1;
|
||||||
|
c.weightx = 1;
|
||||||
|
p.add(field, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caps a field's preferred width and lets it shrink: long device names and wide sliders
|
||||||
|
* would otherwise force the form past the dialog's edge, where the scroll pane (which
|
||||||
|
* never scrolls horizontally) simply clips them.
|
||||||
|
*/
|
||||||
|
static void limitWidth(JComponent comp, int preferredWidth) {
|
||||||
|
int height = comp.getPreferredSize().height;
|
||||||
|
comp.setPreferredSize(new Dimension(preferredWidth, height));
|
||||||
|
comp.setMinimumSize(new Dimension(MIN_FIELD_WIDTH, height));
|
||||||
|
}
|
||||||
|
|
||||||
|
static JPanel sliderWithLabel(JSlider slider, JLabel valueLabel) {
|
||||||
|
return sliderWithLabel(slider, valueLabel, 48);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pairs a slider with its value readout. The readout gets a fixed width wide enough for
|
||||||
|
* the longest value so the slider does not jump around as it is dragged.
|
||||||
|
*/
|
||||||
|
static JPanel sliderWithLabel(JSlider slider, JLabel valueLabel, int labelWidth) {
|
||||||
|
JPanel panel = new JPanel(new BorderLayout(6, 0));
|
||||||
|
limitWidth(slider, SLIDER_WIDTH);
|
||||||
|
panel.add(slider, BorderLayout.CENTER);
|
||||||
|
valueLabel.setPreferredSize(new Dimension(labelWidth, valueLabel.getPreferredSize().height));
|
||||||
|
panel.add(valueLabel, BorderLayout.EAST);
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,98 +1,40 @@
|
|||||||
package com.ts3client.ui;
|
package com.ts3client.ui;
|
||||||
|
|
||||||
import com.ts3client.audio.InputLevel;
|
|
||||||
import com.ts3client.audio.OpusParameters;
|
import com.ts3client.audio.OpusParameters;
|
||||||
import com.ts3client.audio.VoiceInput;
|
import com.ts3client.audio.VoiceInput;
|
||||||
import com.ts3client.audio.VoiceOutput;
|
import com.ts3client.audio.VoiceOutput;
|
||||||
import com.ts3client.audio.desktop.AudioDevices;
|
|
||||||
import com.ts3client.config.Settings;
|
import com.ts3client.config.Settings;
|
||||||
import com.ts3client.hotkey.Hotkey;
|
|
||||||
import com.ts3client.hotkey.HotkeyAction;
|
|
||||||
import com.ts3client.sound.SoundNotifier;
|
import com.ts3client.sound.SoundNotifier;
|
||||||
|
|
||||||
import javax.swing.BorderFactory;
|
|
||||||
import javax.swing.Box;
|
|
||||||
import javax.swing.ButtonGroup;
|
|
||||||
import javax.swing.JButton;
|
import javax.swing.JButton;
|
||||||
import javax.swing.JCheckBox;
|
|
||||||
import javax.swing.JComboBox;
|
|
||||||
import javax.swing.JComponent;
|
|
||||||
import javax.swing.JDialog;
|
import javax.swing.JDialog;
|
||||||
import javax.swing.JLabel;
|
|
||||||
import javax.swing.JPanel;
|
import javax.swing.JPanel;
|
||||||
import javax.swing.JRadioButton;
|
|
||||||
import javax.swing.JSlider;
|
|
||||||
import javax.swing.JTabbedPane;
|
import javax.swing.JTabbedPane;
|
||||||
import javax.swing.JToggleButton;
|
|
||||||
import javax.swing.Scrollable;
|
|
||||||
import javax.swing.SwingUtilities;
|
|
||||||
import java.awt.BorderLayout;
|
import java.awt.BorderLayout;
|
||||||
import java.awt.Dimension;
|
import java.awt.Dimension;
|
||||||
import java.awt.Frame;
|
import java.awt.Frame;
|
||||||
import java.awt.GridBagConstraints;
|
import java.util.function.Consumer;
|
||||||
import java.awt.GridBagLayout;
|
|
||||||
import java.awt.Insets;
|
|
||||||
import java.awt.Rectangle;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options dialog: audio device selection plus voice-activation / push-to-talk
|
* Options dialog: a tabbed coordinator over the individual settings pages. Each tab owns
|
||||||
* tuning with a live input meter. Changes are applied to the running audio
|
* its own controls and reads/writes {@link Settings} on {@link #apply()}; this class wires
|
||||||
* subsystem immediately and persisted to {@link Settings} on OK.
|
* the tabs that preview themselves live against the running audio subsystem (the device
|
||||||
|
* and voice-activation tabs, which share a single microphone test) and owns the
|
||||||
|
* OK/Cancel/Apply plumbing.
|
||||||
*/
|
*/
|
||||||
public final class SettingsDialog extends JDialog {
|
public final class SettingsDialog extends JDialog {
|
||||||
|
|
||||||
/** Preferred widths of the form's field column; rows shrink with the dialog from there. */
|
|
||||||
private static final int FIELD_WIDTH = 240;
|
|
||||||
private static final int SLIDER_WIDTH = 200;
|
|
||||||
private static final int MIN_FIELD_WIDTH = 60;
|
|
||||||
|
|
||||||
private static final int MIN_BITRATE_KBITS = 8;
|
|
||||||
private static final int MAX_BITRATE_KBITS = 160;
|
|
||||||
|
|
||||||
private final Settings settings;
|
private final Settings settings;
|
||||||
private final VoiceInput liveMic;
|
private final VoiceInput liveMic;
|
||||||
private final VoiceOutput livePlayback;
|
private final VoiceOutput livePlayback;
|
||||||
private final SoundNotifier sounds;
|
|
||||||
private final HotkeyService hotkeys;
|
|
||||||
private final Runnable onApply;
|
private final Runnable onApply;
|
||||||
|
|
||||||
private NotificationsPanel notificationsPanel;
|
private final DevicesPanel devicesPanel;
|
||||||
private IconPackPanel iconPackPanel;
|
private final VoiceActivationPanel voiceActivationPanel;
|
||||||
private ClientVersionPanel clientVersionPanel;
|
private final NotificationsPanel notificationsPanel;
|
||||||
private HotkeysPanel hotkeysPanel;
|
private final IconPackPanel iconPackPanel;
|
||||||
|
private final HotkeysPanel hotkeysPanel;
|
||||||
private JComboBox<AudioDevices.Device> inputCombo;
|
private final ClientVersionPanel clientVersionPanel;
|
||||||
private JComboBox<AudioDevices.Device> outputCombo;
|
|
||||||
private JSlider inputGain;
|
|
||||||
private JSlider outputVol;
|
|
||||||
private JCheckBox denoiseCheck;
|
|
||||||
private JSlider denoiseLevel;
|
|
||||||
private JCheckBox typingCheck;
|
|
||||||
private JCheckBox agcCheck;
|
|
||||||
|
|
||||||
private JRadioButton vadRadio;
|
|
||||||
private JRadioButton pttRadio;
|
|
||||||
private JRadioButton contRadio;
|
|
||||||
private JComboBox<String> vadModeCombo;
|
|
||||||
private JSlider thresholdSlider;
|
|
||||||
private JSlider speechSlider;
|
|
||||||
private JCheckBox vadOverPttCheck;
|
|
||||||
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;
|
|
||||||
private JSlider complexitySlider;
|
|
||||||
private JCheckBox vbrCheck;
|
|
||||||
private JCheckBox fecCheck;
|
|
||||||
private JCheckBox musicCheck;
|
|
||||||
|
|
||||||
private final MicrophoneTest micTest = new MicrophoneTest(this::onTestLevel, this::onTestTalking);
|
|
||||||
|
|
||||||
public SettingsDialog(Frame owner, Settings settings,
|
public SettingsDialog(Frame owner, Settings settings,
|
||||||
VoiceInput liveMic, VoiceOutput livePlayback,
|
VoiceInput liveMic, VoiceOutput livePlayback,
|
||||||
@@ -101,20 +43,23 @@ public final class SettingsDialog extends JDialog {
|
|||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
this.liveMic = liveMic;
|
this.liveMic = liveMic;
|
||||||
this.livePlayback = livePlayback;
|
this.livePlayback = livePlayback;
|
||||||
this.sounds = sounds;
|
|
||||||
this.onApply = onApply;
|
this.onApply = onApply;
|
||||||
this.hotkeys = hotkeys;
|
|
||||||
|
notificationsPanel = new NotificationsPanel(settings, sounds);
|
||||||
|
iconPackPanel = new IconPackPanel(settings);
|
||||||
|
hotkeysPanel = new HotkeysPanel(hotkeys);
|
||||||
|
clientVersionPanel = new ClientVersionPanel(settings);
|
||||||
|
devicesPanel = new DevicesPanel(settings, livePlayback,
|
||||||
|
this::applyLive, this::restartTest, this::setTestOutputDevice);
|
||||||
|
voiceActivationPanel = new VoiceActivationPanel(settings, liveMic, hotkeys,
|
||||||
|
hotkeysPanel, this::audioSnapshot);
|
||||||
|
|
||||||
JTabbedPane tabs = new JTabbedPane();
|
JTabbedPane tabs = new JTabbedPane();
|
||||||
tabs.addTab("Playback / Capture", scrollable(buildDevicesTab()));
|
tabs.addTab("Playback / Capture", scrollable(devicesPanel));
|
||||||
tabs.addTab("Voice Activation", scrollable(buildVoiceTab()));
|
tabs.addTab("Voice Activation", scrollable(voiceActivationPanel));
|
||||||
notificationsPanel = new NotificationsPanel(settings, sounds);
|
|
||||||
tabs.addTab("Notifications", notificationsPanel);
|
tabs.addTab("Notifications", notificationsPanel);
|
||||||
iconPackPanel = new IconPackPanel(settings);
|
|
||||||
tabs.addTab("Design", iconPackPanel);
|
tabs.addTab("Design", iconPackPanel);
|
||||||
hotkeysPanel = new HotkeysPanel(hotkeys);
|
|
||||||
tabs.addTab("Hotkeys", hotkeysPanel);
|
tabs.addTab("Hotkeys", hotkeysPanel);
|
||||||
clientVersionPanel = new ClientVersionPanel(settings);
|
|
||||||
tabs.addTab("Client Version", scrollable(clientVersionPanel));
|
tabs.addTab("Client Version", scrollable(clientVersionPanel));
|
||||||
|
|
||||||
JPanel buttons = new JPanel(new BorderLayout());
|
JPanel buttons = new JPanel(new BorderLayout());
|
||||||
@@ -139,7 +84,7 @@ public final class SettingsDialog extends JDialog {
|
|||||||
addWindowListener(new java.awt.event.WindowAdapter() {
|
addWindowListener(new java.awt.event.WindowAdapter() {
|
||||||
@Override
|
@Override
|
||||||
public void windowClosed(java.awt.event.WindowEvent e) {
|
public void windowClosed(java.awt.event.WindowEvent e) {
|
||||||
micTest.stop();
|
voiceActivationPanel.stopTest();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -149,327 +94,6 @@ public final class SettingsDialog extends JDialog {
|
|||||||
setLocationRelativeTo(owner);
|
setLocationRelativeTo(owner);
|
||||||
}
|
}
|
||||||
|
|
||||||
private JPanel buildDevicesTab() {
|
|
||||||
JPanel p = formPanel();
|
|
||||||
GridBagConstraints c = gbc();
|
|
||||||
|
|
||||||
List<AudioDevices.Device> ins = AudioDevices.inputDevices();
|
|
||||||
List<AudioDevices.Device> outs = AudioDevices.outputDevices();
|
|
||||||
|
|
||||||
inputCombo = new JComboBox<>(ins.toArray(new AudioDevices.Device[0]));
|
|
||||||
outputCombo = new JComboBox<>(outs.toArray(new AudioDevices.Device[0]));
|
|
||||||
selectOrDefault(inputCombo, settings.inputDevice);
|
|
||||||
selectOrDefault(outputCombo, settings.outputDevice);
|
|
||||||
|
|
||||||
String deviceHint = "<html>Named devices are PipeWire's, and are routed through it "
|
|
||||||
+ "(so per-application volume and rerouting keep working).<br>"
|
|
||||||
+ "<b>ALSA:</b> entries talk to the sound card directly, taking it exclusively.</html>";
|
|
||||||
inputCombo.setToolTipText(deviceHint);
|
|
||||||
outputCombo.setToolTipText(deviceHint);
|
|
||||||
limitWidth(inputCombo, FIELD_WIDTH);
|
|
||||||
limitWidth(outputCombo, FIELD_WIDTH);
|
|
||||||
|
|
||||||
int row = 0;
|
|
||||||
addRow(p, c, row++, new JLabel("Capture device (microphone):"), inputCombo);
|
|
||||||
addRow(p, c, row++, new JLabel("Playback device (speakers):"), outputCombo);
|
|
||||||
|
|
||||||
inputGain = new JSlider(0, 200, (int) Math.round(settings.inputVolume * 100));
|
|
||||||
outputVol = new JSlider(0, 200, (int) Math.round(settings.outputVolume * 100));
|
|
||||||
limitWidth(inputGain, SLIDER_WIDTH);
|
|
||||||
limitWidth(outputVol, SLIDER_WIDTH);
|
|
||||||
addRow(p, c, row++, new JLabel("Microphone gain:"), inputGain);
|
|
||||||
addRow(p, c, row++, new JLabel("Playback volume:"), outputVol);
|
|
||||||
|
|
||||||
outputVol.addChangeListener(e -> {
|
|
||||||
if (livePlayback != null) livePlayback.setMasterVolume(outputVol.getValue() / 100.0);
|
|
||||||
});
|
|
||||||
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++;
|
|
||||||
c.gridwidth = 2;
|
|
||||||
c.insets = new Insets(14, 4, 2, 4);
|
|
||||||
p.add(new JLabel("Noise reduction"), c);
|
|
||||||
c.insets = new Insets(4, 4, 4, 4);
|
|
||||||
c.gridwidth = 1;
|
|
||||||
|
|
||||||
denoiseCheck = new JCheckBox("Remove background noise", settings.denoise);
|
|
||||||
denoiseCheck.setToolTipText("Attempt to filter out background noises.");
|
|
||||||
denoiseLevel = new JSlider(0, 100, (int) Math.round(settings.denoiserLevel * 100));
|
|
||||||
limitWidth(denoiseLevel, SLIDER_WIDTH);
|
|
||||||
denoiseLevel.setToolTipText("Higher = more aggressive noise removal.");
|
|
||||||
typingCheck = new JCheckBox("Typing attenuation", settings.typingAttenuation);
|
|
||||||
typingCheck.setToolTipText("<html><b>Typing attenuation</b> tries to detect and "
|
|
||||||
+ "reduce the sounds made by typing.</html>");
|
|
||||||
agcCheck = new JCheckBox("Automatic gain control (AGC)", settings.agc);
|
|
||||||
agcCheck.setToolTipText("<html><b>Automatic gain control</b> normalises your "
|
|
||||||
+ "microphone loudness to a target level, boosting quiet mics and taming "
|
|
||||||
+ "loud ones.</html>");
|
|
||||||
|
|
||||||
c.gridx = 0;
|
|
||||||
c.gridy = row++;
|
|
||||||
c.gridwidth = 2;
|
|
||||||
p.add(denoiseCheck, c);
|
|
||||||
c.gridwidth = 1;
|
|
||||||
addRow(p, c, row++, new JLabel("Noise removal level:"), denoiseLevel);
|
|
||||||
c.gridx = 0;
|
|
||||||
c.gridy = row++;
|
|
||||||
c.gridwidth = 2;
|
|
||||||
p.add(typingCheck, c);
|
|
||||||
c.gridy = row++;
|
|
||||||
p.add(agcCheck, c);
|
|
||||||
c.gridwidth = 1;
|
|
||||||
|
|
||||||
Runnable syncNoise = () -> {
|
|
||||||
denoiseLevel.setEnabled(denoiseCheck.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 ->
|
|
||||||
applyLive(m -> m.setDenoiserLevel(denoiseLevel.getValue() / 100.0)));
|
|
||||||
syncNoise.run();
|
|
||||||
|
|
||||||
// filler
|
|
||||||
c.gridx = 0;
|
|
||||||
c.gridy = row;
|
|
||||||
c.weighty = 1;
|
|
||||||
p.add(Box.createGlue(), c);
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
private JPanel buildVoiceTab() {
|
|
||||||
JPanel p = formPanel();
|
|
||||||
GridBagConstraints c = gbc();
|
|
||||||
|
|
||||||
vadRadio = new JRadioButton("Voice Activation Detection");
|
|
||||||
pttRadio = new JRadioButton("Push-To-Talk");
|
|
||||||
contRadio = new JRadioButton("Continuous");
|
|
||||||
ButtonGroup group = new ButtonGroup();
|
|
||||||
group.add(vadRadio);
|
|
||||||
group.add(pttRadio);
|
|
||||||
group.add(contRadio);
|
|
||||||
switch (settings.inputMode) {
|
|
||||||
case PUSH_TO_TALK:
|
|
||||||
pttRadio.setSelected(true);
|
|
||||||
break;
|
|
||||||
case CONTINUOUS:
|
|
||||||
contRadio.setSelected(true);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
vadRadio.setSelected(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
int row = 0;
|
|
||||||
c.gridx = 0;
|
|
||||||
c.gridy = row++;
|
|
||||||
c.gridwidth = 2;
|
|
||||||
p.add(vadRadio, c);
|
|
||||||
c.gridy = row++;
|
|
||||||
p.add(pttRadio, c);
|
|
||||||
c.gridy = row++;
|
|
||||||
p.add(contRadio, c);
|
|
||||||
c.gridwidth = 1;
|
|
||||||
|
|
||||||
meter = new LevelMeter();
|
|
||||||
meter.setThreshold(settings.vadThresholdDb);
|
|
||||||
c.gridx = 0;
|
|
||||||
c.gridy = row;
|
|
||||||
c.gridwidth = 2;
|
|
||||||
c.insets = new Insets(10, 4, 2, 4);
|
|
||||||
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((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");
|
|
||||||
applyLive(m -> m.setThresholdDb(thresholdSlider.getValue()));
|
|
||||||
});
|
|
||||||
addRow(p, c, row++, new JLabel("Volume gate:"), sliderWithLabel(thresholdSlider, thresholdLabel));
|
|
||||||
|
|
||||||
speechSlider = new JSlider(0, 100, (int) Math.round(settings.speechThreshold * 100));
|
|
||||||
speechLabel = new JLabel(Math.round(settings.speechThreshold * 100) + "%");
|
|
||||||
speechSlider.addChangeListener(e -> {
|
|
||||||
speechLabel.setText(speechSlider.getValue() + "%");
|
|
||||||
applyLive(m -> m.setSpeechThreshold(speechSlider.getValue() / 100.0));
|
|
||||||
});
|
|
||||||
addRow(p, c, row++, new JLabel("Speech threshold:"), sliderWithLabel(speechSlider, speechLabel));
|
|
||||||
|
|
||||||
pttKeyButton = new JButton(pushToTalkHotkeyText());
|
|
||||||
pttKeyButton.setToolTipText("Push-to-talk is a global hotkey; this opens its binding");
|
|
||||||
pttKeyButton.addActionListener(e -> editPushToTalkHotkey());
|
|
||||||
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;
|
|
||||||
p.add(vadOverPttCheck, c);
|
|
||||||
c.gridwidth = 1;
|
|
||||||
|
|
||||||
int kbits = Math.max(MIN_BITRATE_KBITS, Math.min(MAX_BITRATE_KBITS, settings.bitrate / 1000));
|
|
||||||
bitrateSlider = new JSlider(MIN_BITRATE_KBITS, MAX_BITRATE_KBITS, kbits);
|
|
||||||
bitrateLabel = new JLabel(kbits + " kbit/s");
|
|
||||||
bitrateSlider.addChangeListener(e -> {
|
|
||||||
bitrateLabel.setText(bitrateSlider.getValue() + " kbit/s");
|
|
||||||
pushOpusLive();
|
|
||||||
});
|
|
||||||
addRow(p, c, row++, new JLabel("Opus bitrate:"),
|
|
||||||
sliderWithLabel(bitrateSlider, bitrateLabel, 70));
|
|
||||||
|
|
||||||
complexitySlider = new JSlider(0, 10, settings.complexity);
|
|
||||||
limitWidth(complexitySlider, SLIDER_WIDTH);
|
|
||||||
complexitySlider.addChangeListener(e -> pushOpusLive());
|
|
||||||
addRow(p, c, row++, new JLabel("Opus complexity:"), complexitySlider);
|
|
||||||
|
|
||||||
vbrCheck = new JCheckBox("Variable bitrate (VBR)", settings.vbr);
|
|
||||||
fecCheck = new JCheckBox("Forward error correction (FEC)", settings.fec);
|
|
||||||
musicCheck = new JCheckBox("Music codec (stereo, higher fidelity)", settings.music);
|
|
||||||
musicCheck.setToolTipText("<html>Transmits <b>OPUS_MUSIC</b>: stereo when the capture "
|
|
||||||
+ "device has two channels, and without the voice pre-processing "
|
|
||||||
+ "(noise removal, typing attenuation, AGC).<br>"
|
|
||||||
+ "Voice mode (<b>OPUS_VOICE</b>) is mono, as in the official client.</html>");
|
|
||||||
vbrCheck.addActionListener(e -> pushOpusLive());
|
|
||||||
fecCheck.addActionListener(e -> pushOpusLive());
|
|
||||||
musicCheck.addActionListener(e -> pushOpusLive());
|
|
||||||
c.gridx = 0;
|
|
||||||
c.gridy = row++;
|
|
||||||
c.gridwidth = 2;
|
|
||||||
p.add(vbrCheck, c);
|
|
||||||
c.gridy = row++;
|
|
||||||
p.add(fecCheck, c);
|
|
||||||
c.gridy = row++;
|
|
||||||
p.add(musicCheck, c);
|
|
||||||
c.gridwidth = 1;
|
|
||||||
|
|
||||||
Runnable syncEnabled = () -> {
|
|
||||||
boolean vad = vadRadio.isSelected();
|
|
||||||
boolean ptt = pttRadio.isSelected();
|
|
||||||
boolean vadContext = vad || (ptt && vadOverPttCheck.isSelected());
|
|
||||||
Settings.VadMode vm = currentVadMode();
|
|
||||||
boolean usesGate = vm != Settings.VadMode.AUTOMATIC;
|
|
||||||
boolean usesSpeech = vm != Settings.VadMode.VOLUME_GATE;
|
|
||||||
|
|
||||||
vadModeCombo.setEnabled(vadContext);
|
|
||||||
thresholdSlider.setEnabled(vadContext && usesGate);
|
|
||||||
speechSlider.setEnabled(vadContext && usesSpeech);
|
|
||||||
pttKeyButton.setEnabled(ptt);
|
|
||||||
vadOverPttCheck.setEnabled(ptt);
|
|
||||||
meter.setShowThreshold(vadContext && usesGate);
|
|
||||||
|
|
||||||
if (liveMic != null) {
|
|
||||||
liveMic.setMode(ptt ? Settings.InputMode.PUSH_TO_TALK
|
|
||||||
: vad ? Settings.InputMode.VOICE_ACTIVATION
|
|
||||||
: Settings.InputMode.CONTINUOUS);
|
|
||||||
liveMic.setVadMode(vm);
|
|
||||||
liveMic.setVadOverPtt(vadOverPttCheck.isSelected());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
vadRadio.addActionListener(e -> syncEnabled.run());
|
|
||||||
pttRadio.addActionListener(e -> syncEnabled.run());
|
|
||||||
contRadio.addActionListener(e -> syncEnabled.run());
|
|
||||||
vadModeCombo.addActionListener(e -> syncEnabled.run());
|
|
||||||
vadOverPttCheck.addActionListener(e -> syncEnabled.run());
|
|
||||||
syncEnabled.run();
|
|
||||||
|
|
||||||
c.gridx = 0;
|
|
||||||
c.gridy = row;
|
|
||||||
c.weighty = 1;
|
|
||||||
p.add(Box.createGlue(), c);
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int vadModeIndex(Settings.VadMode m) {
|
|
||||||
switch (m) {
|
|
||||||
case AUTOMATIC:
|
|
||||||
return 0;
|
|
||||||
case VOLUME_GATE:
|
|
||||||
return 1;
|
|
||||||
default:
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Settings.VadMode currentVadMode() {
|
|
||||||
switch (vadModeCombo.getSelectedIndex()) {
|
|
||||||
case 0:
|
|
||||||
return Settings.VadMode.AUTOMATIC;
|
|
||||||
case 1:
|
|
||||||
return Settings.VadMode.VOLUME_GATE;
|
|
||||||
default:
|
|
||||||
return Settings.VadMode.HYBRID;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The form panel used by both tabs. It follows the scroll pane's width instead of
|
|
||||||
* demanding its own preferred one, so rows stay inside the dialog.
|
|
||||||
*/
|
|
||||||
private static JPanel formPanel() {
|
|
||||||
JPanel p = new FormPanel();
|
|
||||||
p.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class FormPanel extends JPanel implements Scrollable {
|
|
||||||
FormPanel() {
|
|
||||||
super(new GridBagLayout());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Dimension getPreferredScrollableViewportSize() {
|
|
||||||
return getPreferredSize();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int getScrollableUnitIncrement(Rectangle visible, int orientation, int direction) {
|
|
||||||
return 16;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int getScrollableBlockIncrement(Rectangle visible, int orientation, int direction) {
|
|
||||||
return visible.height;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean getScrollableTracksViewportWidth() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean getScrollableTracksViewportHeight() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static javax.swing.JScrollPane scrollable(JPanel content) {
|
private static javax.swing.JScrollPane scrollable(JPanel content) {
|
||||||
javax.swing.JScrollPane sp = new javax.swing.JScrollPane(content,
|
javax.swing.JScrollPane sp = new javax.swing.JScrollPane(content,
|
||||||
javax.swing.ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
|
javax.swing.ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
|
||||||
@@ -479,103 +103,15 @@ public final class SettingsDialog extends JDialog {
|
|||||||
return sp;
|
return sp;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static JPanel sliderWithLabel(JSlider slider, JLabel valueLabel) {
|
/** Copies the audio tabs into {@code target}, without touching anything else. */
|
||||||
return sliderWithLabel(slider, valueLabel, 48);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pairs a slider with its value readout. The readout gets a fixed width wide enough for
|
|
||||||
* the longest value so the slider does not jump around as it is dragged.
|
|
||||||
*/
|
|
||||||
private static JPanel sliderWithLabel(JSlider slider, JLabel valueLabel, int labelWidth) {
|
|
||||||
JPanel panel = new JPanel(new BorderLayout(6, 0));
|
|
||||||
limitWidth(slider, SLIDER_WIDTH);
|
|
||||||
panel.add(slider, BorderLayout.CENTER);
|
|
||||||
valueLabel.setPreferredSize(new Dimension(labelWidth, valueLabel.getPreferredSize().height));
|
|
||||||
panel.add(valueLabel, BorderLayout.EAST);
|
|
||||||
return panel;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Caps a field's preferred width and lets it shrink: long device names and wide sliders
|
|
||||||
* would otherwise force the form past the dialog's edge, where the scroll pane (which
|
|
||||||
* never scrolls horizontally) simply clips them.
|
|
||||||
*/
|
|
||||||
private static void limitWidth(JComponent comp, int preferredWidth) {
|
|
||||||
int height = comp.getPreferredSize().height;
|
|
||||||
comp.setPreferredSize(new Dimension(preferredWidth, height));
|
|
||||||
comp.setMinimumSize(new Dimension(MIN_FIELD_WIDTH, height));
|
|
||||||
}
|
|
||||||
|
|
||||||
private OpusParameters currentOpusParameters() {
|
|
||||||
return new OpusParameters(
|
|
||||||
bitrateSlider.getValue() * 1000,
|
|
||||||
complexitySlider.getValue(),
|
|
||||||
vbrCheck.isSelected(),
|
|
||||||
fecCheck.isSelected(),
|
|
||||||
settings.packetLoss,
|
|
||||||
musicCheck.isSelected());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Applies the current Opus controls to the running encoder immediately. */
|
|
||||||
private void pushOpusLive() {
|
|
||||||
if (liveMic != null) liveMic.setOpusParameters(currentOpusParameters());
|
|
||||||
}
|
|
||||||
|
|
||||||
private String pushToTalkHotkeyText() {
|
|
||||||
Hotkey ptt = hotkeys.find(HotkeyAction.PTT_ACTIVATE);
|
|
||||||
return ptt == null ? "No hotkey assigned" : hotkeys.display(ptt.combo);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Push-to-talk is an ordinary hotkey, so this shortcut edits that binding — adding
|
|
||||||
* it when there is none — rather than keeping a key of its own.
|
|
||||||
*/
|
|
||||||
private void editPushToTalkHotkey() {
|
|
||||||
Hotkey existing = hotkeys.find(HotkeyAction.PTT_ACTIVATE);
|
|
||||||
HotkeyDialog dlg = new HotkeyDialog(this, hotkeys,
|
|
||||||
existing == null ? new Hotkey(HotkeyAction.PTT_ACTIVATE, null) : existing);
|
|
||||||
dlg.setVisible(true);
|
|
||||||
if (!dlg.isConfirmed()) return;
|
|
||||||
List<Hotkey> updated = new java.util.ArrayList<>();
|
|
||||||
synchronized (hotkeys.all()) {
|
|
||||||
for (Hotkey h : hotkeys.all()) {
|
|
||||||
if (h != existing) updated.add(h.copy());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updated.add(dlg.result());
|
|
||||||
hotkeys.replaceAll(updated);
|
|
||||||
pttKeyButton.setText(pushToTalkHotkeyText());
|
|
||||||
hotkeysPanel.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Copies the audio form into {@code target}, without touching anything else. */
|
|
||||||
private void writeAudioSettings(Settings target) {
|
private void writeAudioSettings(Settings target) {
|
||||||
target.inputDevice = comboValue(inputCombo);
|
devicesPanel.writeInto(target);
|
||||||
target.outputDevice = comboValue(outputCombo);
|
voiceActivationPanel.writeInto(target);
|
||||||
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;
|
|
||||||
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
|
* The audio tabs as a standalone {@link Settings}, so the microphone test chain runs
|
||||||
* values currently on screen rather than the ones last saved.
|
* 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
|
* <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.
|
* would need the global hotkey, which belongs to the connected microphone.
|
||||||
@@ -625,129 +161,27 @@ public final class SettingsDialog extends JDialog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void close() {
|
private void close() {
|
||||||
micTest.stop();
|
voiceActivationPanel.stopTest();
|
||||||
dispose();
|
dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- microphone test ----
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The test row: a toggle that runs the capture chain, an indicator showing whether the
|
* Applies a live change to the connected microphone and to the microphone test alike.
|
||||||
* gate is open, and an optional loopback so you can hear what is being sent.
|
*
|
||||||
|
* <p>Guarded against a null {@code voiceActivationPanel}: the Devices tab applies its
|
||||||
|
* controls once as they are built, which happens before that tab exists.
|
||||||
*/
|
*/
|
||||||
private JPanel buildTestControls() {
|
private void applyLive(Consumer<VoiceInput> change) {
|
||||||
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 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 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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);
|
if (liveMic != null) change.accept(liveMic);
|
||||||
micTest.configure(change);
|
if (voiceActivationPanel != null) voiceActivationPanel.configureTest(change);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void onTestLevel(double db) {
|
/** Restarts the microphone test, if running, so a device change takes effect. */
|
||||||
if (meter != null) meter.setLevel(db);
|
private void restartTest() {
|
||||||
|
if (voiceActivationPanel != null) voiceActivationPanel.restartTest();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void onTestTalking(boolean talking) {
|
private void setTestOutputDevice(String deviceId) {
|
||||||
if (meter != null) meter.setTransmitting(talking);
|
if (voiceActivationPanel != null) voiceActivationPanel.setTestOutputDevice(deviceId);
|
||||||
if (talkIndicator != null) {
|
|
||||||
talkIndicator.setIcon(talking ? Icons.clientTalking() : Icons.clientIdle());
|
|
||||||
talkIndicator.setText(talking ? "Transmitting" : "Not transmitting");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- small helpers ----
|
|
||||||
|
|
||||||
private static GridBagConstraints gbc() {
|
|
||||||
GridBagConstraints c = new GridBagConstraints();
|
|
||||||
c.insets = new Insets(4, 4, 4, 4);
|
|
||||||
c.anchor = GridBagConstraints.WEST;
|
|
||||||
c.fill = GridBagConstraints.HORIZONTAL;
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void addRow(JPanel p, GridBagConstraints c, int row, JLabel label, java.awt.Component field) {
|
|
||||||
c.gridx = 0;
|
|
||||||
c.gridy = row;
|
|
||||||
c.weightx = 0;
|
|
||||||
c.gridwidth = 1;
|
|
||||||
p.add(label, c);
|
|
||||||
c.gridx = 1;
|
|
||||||
c.weightx = 1;
|
|
||||||
p.add(field, c);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void selectOrDefault(JComboBox<AudioDevices.Device> combo, String deviceId) {
|
|
||||||
if (deviceId != null && !deviceId.isEmpty()) {
|
|
||||||
for (int i = 0; i < combo.getItemCount(); i++) {
|
|
||||||
if (deviceId.equals(combo.getItemAt(i).id())) {
|
|
||||||
combo.setSelectedIndex(i);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
combo.setSelectedIndex(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String comboValue(JComboBox<AudioDevices.Device> combo) {
|
|
||||||
AudioDevices.Device d = (AudioDevices.Device) combo.getSelectedItem();
|
|
||||||
return d == null ? "" : d.id();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,406 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.audio.InputLevel;
|
||||||
|
import com.ts3client.audio.OpusParameters;
|
||||||
|
import com.ts3client.audio.VoiceInput;
|
||||||
|
import com.ts3client.config.Settings;
|
||||||
|
import com.ts3client.hotkey.Hotkey;
|
||||||
|
import com.ts3client.hotkey.HotkeyAction;
|
||||||
|
|
||||||
|
import javax.swing.Box;
|
||||||
|
import javax.swing.ButtonGroup;
|
||||||
|
import javax.swing.JButton;
|
||||||
|
import javax.swing.JCheckBox;
|
||||||
|
import javax.swing.JComboBox;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.JRadioButton;
|
||||||
|
import javax.swing.JSlider;
|
||||||
|
import javax.swing.JToggleButton;
|
||||||
|
import java.awt.GridBagConstraints;
|
||||||
|
import java.awt.Insets;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options dialog's "Voice Activation" tab: input mode (VAD / push-to-talk / continuous),
|
||||||
|
* the detection tuning with a live input meter, and the Opus encoder controls.
|
||||||
|
*
|
||||||
|
* <p>Owns the microphone test (start/stop button, loopback, indicator): it runs a real
|
||||||
|
* capture chain built from the form's current values, plus whatever the "Playback / Capture"
|
||||||
|
* tab currently has on screen, via {@code audioSnapshot}.
|
||||||
|
*/
|
||||||
|
final class VoiceActivationPanel extends FormPanel {
|
||||||
|
|
||||||
|
private static final int MIN_BITRATE_KBITS = 8;
|
||||||
|
private static final int MAX_BITRATE_KBITS = 160;
|
||||||
|
|
||||||
|
private final Settings settings;
|
||||||
|
private final VoiceInput liveMic;
|
||||||
|
private final HotkeyService hotkeys;
|
||||||
|
private final HotkeysPanel hotkeysPanel;
|
||||||
|
private final Supplier<Settings> audioSnapshot;
|
||||||
|
private final MicrophoneTest micTest = new MicrophoneTest(this::onTestLevel, this::onTestTalking);
|
||||||
|
|
||||||
|
private final JRadioButton vadRadio;
|
||||||
|
private final JRadioButton pttRadio;
|
||||||
|
private final JRadioButton contRadio;
|
||||||
|
private final JComboBox<String> vadModeCombo;
|
||||||
|
private final JSlider thresholdSlider;
|
||||||
|
private final JSlider speechSlider;
|
||||||
|
private final JCheckBox vadOverPttCheck;
|
||||||
|
private final LevelMeter meter;
|
||||||
|
private JToggleButton testButton;
|
||||||
|
private JCheckBox loopbackCheck;
|
||||||
|
private JLabel talkIndicator;
|
||||||
|
private final JButton pttKeyButton;
|
||||||
|
private final JSlider bitrateSlider;
|
||||||
|
private final JSlider complexitySlider;
|
||||||
|
private final JCheckBox vbrCheck;
|
||||||
|
private final JCheckBox fecCheck;
|
||||||
|
private final JCheckBox musicCheck;
|
||||||
|
|
||||||
|
VoiceActivationPanel(Settings settings, VoiceInput liveMic, HotkeyService hotkeys,
|
||||||
|
HotkeysPanel hotkeysPanel, Supplier<Settings> audioSnapshot) {
|
||||||
|
this.settings = settings;
|
||||||
|
this.liveMic = liveMic;
|
||||||
|
this.hotkeys = hotkeys;
|
||||||
|
this.hotkeysPanel = hotkeysPanel;
|
||||||
|
this.audioSnapshot = audioSnapshot;
|
||||||
|
|
||||||
|
GridBagConstraints c = gbc();
|
||||||
|
|
||||||
|
vadRadio = new JRadioButton("Voice Activation Detection");
|
||||||
|
pttRadio = new JRadioButton("Push-To-Talk");
|
||||||
|
contRadio = new JRadioButton("Continuous");
|
||||||
|
ButtonGroup group = new ButtonGroup();
|
||||||
|
group.add(vadRadio);
|
||||||
|
group.add(pttRadio);
|
||||||
|
group.add(contRadio);
|
||||||
|
switch (settings.inputMode) {
|
||||||
|
case PUSH_TO_TALK:
|
||||||
|
pttRadio.setSelected(true);
|
||||||
|
break;
|
||||||
|
case CONTINUOUS:
|
||||||
|
contRadio.setSelected(true);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
vadRadio.setSelected(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
int row = 0;
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = row++;
|
||||||
|
c.gridwidth = 2;
|
||||||
|
add(vadRadio, c);
|
||||||
|
c.gridy = row++;
|
||||||
|
add(pttRadio, c);
|
||||||
|
c.gridy = row++;
|
||||||
|
add(contRadio, c);
|
||||||
|
c.gridwidth = 1;
|
||||||
|
|
||||||
|
meter = new LevelMeter();
|
||||||
|
meter.setThreshold(settings.vadThresholdDb);
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = row;
|
||||||
|
c.gridwidth = 2;
|
||||||
|
c.insets = new Insets(10, 4, 2, 4);
|
||||||
|
add(new JLabel("Input level:"), c);
|
||||||
|
c.gridy = ++row;
|
||||||
|
add(meter, c);
|
||||||
|
c.insets = new Insets(4, 4, 4, 4);
|
||||||
|
c.gridy = ++row;
|
||||||
|
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(this, c, row++, new JLabel("Detection:"), vadModeCombo);
|
||||||
|
|
||||||
|
thresholdSlider = new JSlider((int) InputLevel.MIN_DB, (int) InputLevel.MAX_DB,
|
||||||
|
(int) Math.round(settings.vadThresholdDb));
|
||||||
|
JLabel thresholdLabel = new JLabel(Math.round(settings.vadThresholdDb) + " dB");
|
||||||
|
thresholdSlider.addChangeListener(e -> {
|
||||||
|
meter.setThreshold(thresholdSlider.getValue());
|
||||||
|
thresholdLabel.setText(thresholdSlider.getValue() + " dB");
|
||||||
|
applyLive(m -> m.setThresholdDb(thresholdSlider.getValue()));
|
||||||
|
});
|
||||||
|
addRow(this, c, row++, new JLabel("Volume gate:"), sliderWithLabel(thresholdSlider, thresholdLabel));
|
||||||
|
|
||||||
|
speechSlider = new JSlider(0, 100, (int) Math.round(settings.speechThreshold * 100));
|
||||||
|
JLabel speechLabel = new JLabel(Math.round(settings.speechThreshold * 100) + "%");
|
||||||
|
speechSlider.addChangeListener(e -> {
|
||||||
|
speechLabel.setText(speechSlider.getValue() + "%");
|
||||||
|
applyLive(m -> m.setSpeechThreshold(speechSlider.getValue() / 100.0));
|
||||||
|
});
|
||||||
|
addRow(this, c, row++, new JLabel("Speech threshold:"), sliderWithLabel(speechSlider, speechLabel));
|
||||||
|
|
||||||
|
pttKeyButton = new JButton(pushToTalkHotkeyText());
|
||||||
|
pttKeyButton.setToolTipText("Push-to-talk is a global hotkey; this opens its binding");
|
||||||
|
pttKeyButton.addActionListener(e -> editPushToTalkHotkey());
|
||||||
|
addRow(this, 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;
|
||||||
|
add(vadOverPttCheck, c);
|
||||||
|
c.gridwidth = 1;
|
||||||
|
|
||||||
|
int kbits = Math.max(MIN_BITRATE_KBITS, Math.min(MAX_BITRATE_KBITS, settings.bitrate / 1000));
|
||||||
|
bitrateSlider = new JSlider(MIN_BITRATE_KBITS, MAX_BITRATE_KBITS, kbits);
|
||||||
|
JLabel bitrateLabel = new JLabel(kbits + " kbit/s");
|
||||||
|
bitrateSlider.addChangeListener(e -> {
|
||||||
|
bitrateLabel.setText(bitrateSlider.getValue() + " kbit/s");
|
||||||
|
pushOpusLive();
|
||||||
|
});
|
||||||
|
addRow(this, c, row++, new JLabel("Opus bitrate:"),
|
||||||
|
sliderWithLabel(bitrateSlider, bitrateLabel, 70));
|
||||||
|
|
||||||
|
complexitySlider = new JSlider(0, 10, settings.complexity);
|
||||||
|
limitWidth(complexitySlider, SLIDER_WIDTH);
|
||||||
|
complexitySlider.addChangeListener(e -> pushOpusLive());
|
||||||
|
addRow(this, c, row++, new JLabel("Opus complexity:"), complexitySlider);
|
||||||
|
|
||||||
|
vbrCheck = new JCheckBox("Variable bitrate (VBR)", settings.vbr);
|
||||||
|
fecCheck = new JCheckBox("Forward error correction (FEC)", settings.fec);
|
||||||
|
musicCheck = new JCheckBox("Music codec (stereo, higher fidelity)", settings.music);
|
||||||
|
musicCheck.setToolTipText("<html>Transmits <b>OPUS_MUSIC</b>: stereo when the capture "
|
||||||
|
+ "device has two channels, and without the voice pre-processing "
|
||||||
|
+ "(noise removal, typing attenuation, AGC).<br>"
|
||||||
|
+ "Voice mode (<b>OPUS_VOICE</b>) is mono, as in the official client.</html>");
|
||||||
|
vbrCheck.addActionListener(e -> pushOpusLive());
|
||||||
|
fecCheck.addActionListener(e -> pushOpusLive());
|
||||||
|
musicCheck.addActionListener(e -> pushOpusLive());
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = row++;
|
||||||
|
c.gridwidth = 2;
|
||||||
|
add(vbrCheck, c);
|
||||||
|
c.gridy = row++;
|
||||||
|
add(fecCheck, c);
|
||||||
|
c.gridy = row++;
|
||||||
|
add(musicCheck, c);
|
||||||
|
c.gridwidth = 1;
|
||||||
|
|
||||||
|
Runnable syncEnabled = () -> {
|
||||||
|
boolean vad = vadRadio.isSelected();
|
||||||
|
boolean ptt = pttRadio.isSelected();
|
||||||
|
boolean vadContext = vad || (ptt && vadOverPttCheck.isSelected());
|
||||||
|
Settings.VadMode vm = currentVadMode();
|
||||||
|
boolean usesGate = vm != Settings.VadMode.AUTOMATIC;
|
||||||
|
boolean usesSpeech = vm != Settings.VadMode.VOLUME_GATE;
|
||||||
|
|
||||||
|
vadModeCombo.setEnabled(vadContext);
|
||||||
|
thresholdSlider.setEnabled(vadContext && usesGate);
|
||||||
|
speechSlider.setEnabled(vadContext && usesSpeech);
|
||||||
|
pttKeyButton.setEnabled(ptt);
|
||||||
|
vadOverPttCheck.setEnabled(ptt);
|
||||||
|
meter.setShowThreshold(vadContext && usesGate);
|
||||||
|
|
||||||
|
if (liveMic != null) {
|
||||||
|
liveMic.setMode(ptt ? Settings.InputMode.PUSH_TO_TALK
|
||||||
|
: vad ? Settings.InputMode.VOICE_ACTIVATION
|
||||||
|
: Settings.InputMode.CONTINUOUS);
|
||||||
|
liveMic.setVadMode(vm);
|
||||||
|
liveMic.setVadOverPtt(vadOverPttCheck.isSelected());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
vadRadio.addActionListener(e -> syncEnabled.run());
|
||||||
|
pttRadio.addActionListener(e -> syncEnabled.run());
|
||||||
|
contRadio.addActionListener(e -> syncEnabled.run());
|
||||||
|
vadModeCombo.addActionListener(e -> syncEnabled.run());
|
||||||
|
vadOverPttCheck.addActionListener(e -> syncEnabled.run());
|
||||||
|
syncEnabled.run();
|
||||||
|
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = row;
|
||||||
|
c.weighty = 1;
|
||||||
|
add(Box.createGlue(), c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies the form into {@code target}, without touching anything else. */
|
||||||
|
void writeInto(Settings target) {
|
||||||
|
target.inputMode = pttRadio.isSelected() ? Settings.InputMode.PUSH_TO_TALK
|
||||||
|
: contRadio.isSelected() ? Settings.InputMode.CONTINUOUS
|
||||||
|
: Settings.InputMode.VOICE_ACTIVATION;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies a change to the microphone test, if it is running; used by the Devices tab too. */
|
||||||
|
void configureTest(Consumer<VoiceInput> change) {
|
||||||
|
micTest.configure(change);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The playback device to loop the test through; used by the Devices tab's output picker. */
|
||||||
|
void setTestOutputDevice(String deviceId) {
|
||||||
|
micTest.setOutputDevice(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restarts the test chain, if running, so a device change on the Devices tab takes effect. */
|
||||||
|
void restartTest() {
|
||||||
|
if (micTest.isRunning()) {
|
||||||
|
boolean loopback = loopbackCheck.isSelected();
|
||||||
|
if (micTest.start(audioSnapshot.get())) {
|
||||||
|
micTest.setLoopback(loopback);
|
||||||
|
} else {
|
||||||
|
setTesting(false);
|
||||||
|
testButton.setSelected(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void stopTest() {
|
||||||
|
micTest.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int vadModeIndex(Settings.VadMode m) {
|
||||||
|
switch (m) {
|
||||||
|
case AUTOMATIC:
|
||||||
|
return 0;
|
||||||
|
case VOLUME_GATE:
|
||||||
|
return 1;
|
||||||
|
default:
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Settings.VadMode currentVadMode() {
|
||||||
|
switch (vadModeCombo.getSelectedIndex()) {
|
||||||
|
case 0:
|
||||||
|
return Settings.VadMode.AUTOMATIC;
|
||||||
|
case 1:
|
||||||
|
return Settings.VadMode.VOLUME_GATE;
|
||||||
|
default:
|
||||||
|
return Settings.VadMode.HYBRID;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private OpusParameters currentOpusParameters() {
|
||||||
|
return new OpusParameters(
|
||||||
|
bitrateSlider.getValue() * 1000,
|
||||||
|
complexitySlider.getValue(),
|
||||||
|
vbrCheck.isSelected(),
|
||||||
|
fecCheck.isSelected(),
|
||||||
|
settings.packetLoss,
|
||||||
|
musicCheck.isSelected());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies the current Opus controls to the running encoder immediately. */
|
||||||
|
private void pushOpusLive() {
|
||||||
|
if (liveMic != null) liveMic.setOpusParameters(currentOpusParameters());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String pushToTalkHotkeyText() {
|
||||||
|
Hotkey ptt = hotkeys.find(HotkeyAction.PTT_ACTIVATE);
|
||||||
|
return ptt == null ? "No hotkey assigned" : hotkeys.display(ptt.combo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push-to-talk is an ordinary hotkey, so this shortcut edits that binding — adding
|
||||||
|
* it when there is none — rather than keeping a key of its own.
|
||||||
|
*/
|
||||||
|
private void editPushToTalkHotkey() {
|
||||||
|
Hotkey existing = hotkeys.find(HotkeyAction.PTT_ACTIVATE);
|
||||||
|
HotkeyDialog dlg = new HotkeyDialog(javax.swing.SwingUtilities.getWindowAncestor(this),
|
||||||
|
hotkeys, existing == null ? new Hotkey(HotkeyAction.PTT_ACTIVATE, null) : existing);
|
||||||
|
dlg.setVisible(true);
|
||||||
|
if (!dlg.isConfirmed()) return;
|
||||||
|
List<Hotkey> updated = new java.util.ArrayList<>();
|
||||||
|
synchronized (hotkeys.all()) {
|
||||||
|
for (Hotkey h : hotkeys.all()) {
|
||||||
|
if (h != existing) updated.add(h.copy());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updated.add(dlg.result());
|
||||||
|
hotkeys.replaceAll(updated);
|
||||||
|
pttKeyButton.setText(pushToTalkHotkeyText());
|
||||||
|
hotkeysPanel.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies a live change to the connected microphone and to the test one alike. */
|
||||||
|
private void applyLive(Consumer<VoiceInput> change) {
|
||||||
|
if (liveMic != null) change.accept(liveMic);
|
||||||
|
micTest.configure(change);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- microphone test ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 setTesting(boolean on) {
|
||||||
|
if (on && !micTest.start(audioSnapshot.get())) {
|
||||||
|
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 resetTestIndicators() {
|
||||||
|
onTestTalking(false);
|
||||||
|
onTestLevel(InputLevel.SILENCE_DB);
|
||||||
|
}
|
||||||
|
|
||||||
|
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