From d722377508eae05e8f7ca7bfcf6799f04ede7878 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Tue, 18 Aug 2026 13:37:51 +0000 Subject: [PATCH] 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 --- .../java/com/ts3client/ui/DevicesPanel.java | 160 +++++ .../main/java/com/ts3client/ui/FormPanel.java | 104 +++ .../java/com/ts3client/ui/SettingsDialog.java | 648 ++---------------- .../ts3client/ui/VoiceActivationPanel.java | 406 +++++++++++ 4 files changed, 711 insertions(+), 607 deletions(-) create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/DevicesPanel.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/FormPanel.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/VoiceActivationPanel.java diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/DevicesPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/DevicesPanel.java new file mode 100644 index 0000000..f7fc663 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/DevicesPanel.java @@ -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 inputCombo; + private final JComboBox 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> applyLive, + Runnable onInputDeviceChanged, Consumer onOutputDeviceChanged) { + GridBagConstraints c = gbc(); + + List ins = AudioDevices.inputDevices(); + List 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 = "Named devices are PipeWire's, and are routed through it " + + "(so per-application volume and rerouting keep working).
" + + "ALSA: entries talk to the sound card directly, taking it exclusively."; + 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("Typing attenuation tries to detect and " + + "reduce the sounds made by typing."); + agcCheck = new JCheckBox("Automatic gain control (AGC)", settings.agc); + agcCheck.setToolTipText("Automatic gain control normalises your " + + "microphone loudness to a target level, boosting quiet mics and taming " + + "loud ones."); + + 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 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 combo) { + AudioDevices.Device d = (AudioDevices.Device) combo.getSelectedItem(); + return d == null ? "" : d.id(); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/FormPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/FormPanel.java new file mode 100644 index 0000000..7b207cf --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/FormPanel.java @@ -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; + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java index 99c81be..815367d 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java @@ -1,98 +1,40 @@ 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.AudioDevices; import com.ts3client.config.Settings; -import com.ts3client.hotkey.Hotkey; -import com.ts3client.hotkey.HotkeyAction; import com.ts3client.sound.SoundNotifier; -import javax.swing.BorderFactory; -import javax.swing.Box; -import javax.swing.ButtonGroup; import javax.swing.JButton; -import javax.swing.JCheckBox; -import javax.swing.JComboBox; -import javax.swing.JComponent; import javax.swing.JDialog; -import javax.swing.JLabel; 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; import java.awt.Dimension; import java.awt.Frame; -import java.awt.GridBagConstraints; -import java.awt.GridBagLayout; -import java.awt.Insets; -import java.awt.Rectangle; -import java.util.List; +import java.util.function.Consumer; /** - * Options dialog: audio device selection plus voice-activation / push-to-talk - * tuning with a live input meter. Changes are applied to the running audio - * subsystem immediately and persisted to {@link Settings} on OK. + * Options dialog: a tabbed coordinator over the individual settings pages. Each tab owns + * its own controls and reads/writes {@link Settings} on {@link #apply()}; this class wires + * 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 { - /** 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 VoiceInput liveMic; private final VoiceOutput livePlayback; - private final SoundNotifier sounds; - private final HotkeyService hotkeys; private final Runnable onApply; - private NotificationsPanel notificationsPanel; - private IconPackPanel iconPackPanel; - private ClientVersionPanel clientVersionPanel; - private HotkeysPanel hotkeysPanel; - - private JComboBox inputCombo; - private JComboBox 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 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); + private final DevicesPanel devicesPanel; + private final VoiceActivationPanel voiceActivationPanel; + private final NotificationsPanel notificationsPanel; + private final IconPackPanel iconPackPanel; + private final HotkeysPanel hotkeysPanel; + private final ClientVersionPanel clientVersionPanel; public SettingsDialog(Frame owner, Settings settings, VoiceInput liveMic, VoiceOutput livePlayback, @@ -101,20 +43,23 @@ public final class SettingsDialog extends JDialog { this.settings = settings; this.liveMic = liveMic; this.livePlayback = livePlayback; - this.sounds = sounds; 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(); - tabs.addTab("Playback / Capture", scrollable(buildDevicesTab())); - tabs.addTab("Voice Activation", scrollable(buildVoiceTab())); - notificationsPanel = new NotificationsPanel(settings, sounds); + tabs.addTab("Playback / Capture", scrollable(devicesPanel)); + tabs.addTab("Voice Activation", scrollable(voiceActivationPanel)); tabs.addTab("Notifications", notificationsPanel); - iconPackPanel = new IconPackPanel(settings); tabs.addTab("Design", iconPackPanel); - hotkeysPanel = new HotkeysPanel(hotkeys); tabs.addTab("Hotkeys", hotkeysPanel); - clientVersionPanel = new ClientVersionPanel(settings); tabs.addTab("Client Version", scrollable(clientVersionPanel)); JPanel buttons = new JPanel(new BorderLayout()); @@ -139,7 +84,7 @@ public final class SettingsDialog extends JDialog { addWindowListener(new java.awt.event.WindowAdapter() { @Override public void windowClosed(java.awt.event.WindowEvent e) { - micTest.stop(); + voiceActivationPanel.stopTest(); } }); @@ -149,327 +94,6 @@ public final class SettingsDialog extends JDialog { setLocationRelativeTo(owner); } - private JPanel buildDevicesTab() { - JPanel p = formPanel(); - GridBagConstraints c = gbc(); - - List ins = AudioDevices.inputDevices(); - List 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 = "Named devices are PipeWire's, and are routed through it " - + "(so per-application volume and rerouting keep working).
" - + "ALSA: entries talk to the sound card directly, taking it exclusively."; - 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("Typing attenuation tries to detect and " - + "reduce the sounds made by typing."); - agcCheck = new JCheckBox("Automatic gain control (AGC)", settings.agc); - agcCheck.setToolTipText("Automatic gain control normalises your " - + "microphone loudness to a target level, boosting quiet mics and taming " - + "loud ones."); - - 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("Automatic: intelligent speech detection.
" - + "Volume Gate: transmit when loud enough.
" - + "Hybrid: loud enough and detected as speech."); - 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("Transmits OPUS_MUSIC: stereo when the capture " - + "device has two channels, and without the voice pre-processing " - + "(noise removal, typing attenuation, AGC).
" - + "Voice mode (OPUS_VOICE) is mono, as in the official client."); - 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) { javax.swing.JScrollPane sp = new javax.swing.JScrollPane(content, javax.swing.ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, @@ -479,103 +103,15 @@ public final class SettingsDialog extends JDialog { return sp; } - private 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. - */ - 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 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. */ + /** Copies the audio tabs 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; - 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(); + devicesPanel.writeInto(target); + voiceActivationPanel.writeInto(target); } /** - * 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. + * The audio tabs as a standalone {@link Settings}, so the microphone test chain runs + * with the values currently on screen rather than the ones last saved. * *

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. @@ -625,129 +161,27 @@ public final class SettingsDialog extends JDialog { } private void close() { - micTest.stop(); + voiceActivationPanel.stopTest(); dispose(); } - // ---- 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. + * Applies a live change to the connected microphone and to the microphone test alike. + * + *

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() { - 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 change) { + private void applyLive(Consumer change) { if (liveMic != null) change.accept(liveMic); - micTest.configure(change); + if (voiceActivationPanel != null) voiceActivationPanel.configureTest(change); } - private void onTestLevel(double db) { - if (meter != null) meter.setLevel(db); + /** Restarts the microphone test, if running, so a device change takes effect. */ + private void restartTest() { + if (voiceActivationPanel != null) voiceActivationPanel.restartTest(); } - 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"); - } - } - - // ---- 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 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 combo) { - AudioDevices.Device d = (AudioDevices.Device) combo.getSelectedItem(); - return d == null ? "" : d.id(); + private void setTestOutputDevice(String deviceId) { + if (voiceActivationPanel != null) voiceActivationPanel.setTestOutputDevice(deviceId); } } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/VoiceActivationPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/VoiceActivationPanel.java new file mode 100644 index 0000000..bf8c021 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/VoiceActivationPanel.java @@ -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. + * + *

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 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 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 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("Automatic: intelligent speech detection.
" + + "Volume Gate: transmit when loud enough.
" + + "Hybrid: loud enough and detected as speech."); + 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("Transmits OPUS_MUSIC: stereo when the capture " + + "device has two channels, and without the voice pre-processing " + + "(noise removal, typing attenuation, AGC).
" + + "Voice mode (OPUS_VOICE) is mono, as in the official client."); + 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 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 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 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"); + } + } +}