Move the voice pipelines into core

Capture processing, the jitter buffer and decoding were desktop classes,
so any other platform would have had to copy them. They now live in core
and reach the platform only through two interfaces:

- AudioIo lists devices and opens capture/playback lines; the desktop's
  AudioDevices implements it over PipeWire and Java Sound.
- OpusCodec creates encoders and decoders; the desktop binds libopus
  through the FFM API as before.

DesktopVoiceInput becomes CaptureVoiceInput unchanged in behaviour.
DesktopVoiceOutput splits into VoiceStream, one speaker's jitter buffer
and decoder, paced by whoever pulls it, and StreamingVoiceOutput around
it. Speakers reach the device either on a line each (the desktop, so
each shows up in the PipeWire mixer) or mixed onto one shared line, as
mobile audio APIs want.

The settings dialog's device lists and microphone test now go through
the AudioBackend instead of desktop classes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 08:01:28 +00:00
parent 3c389de36b
commit 7396c4ca40
36 changed files with 1290 additions and 531 deletions

View File

@@ -1,9 +1,10 @@
package com.ts3client.ui;
import com.formdev.flatlaf.util.UIScale;
import com.ts3client.audio.AudioDevice;
import com.ts3client.audio.AudioIo;
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;
@@ -24,8 +25,8 @@ import java.util.function.Consumer;
*/
final class DevicesPanel extends FormPanel {
private final JComboBox<AudioDevices.Device> inputCombo;
private final JComboBox<AudioDevices.Device> outputCombo;
private final JComboBox<AudioDevice> inputCombo;
private final JComboBox<AudioDevice> outputCombo;
private final JSlider inputGain;
private final JSlider outputVol;
private final JCheckBox denoiseCheck;
@@ -34,16 +35,16 @@ final class DevicesPanel extends FormPanel {
private final JCheckBox agcCheck;
private final JCheckBox mutedWarningCheck;
DevicesPanel(Settings settings, VoiceOutput livePlayback,
DevicesPanel(Settings settings, AudioIo io, 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();
List<AudioDevice> ins = io.inputDevices();
List<AudioDevice> outs = io.outputDevices();
inputCombo = new JComboBox<>(ins.toArray(new AudioDevices.Device[0]));
outputCombo = new JComboBox<>(outs.toArray(new AudioDevices.Device[0]));
inputCombo = new JComboBox<>(ins.toArray(new AudioDevice[0]));
outputCombo = new JComboBox<>(outs.toArray(new AudioDevice[0]));
selectOrDefault(inputCombo, settings.inputDevice);
selectOrDefault(outputCombo, settings.outputDevice);
@@ -158,7 +159,7 @@ final class DevicesPanel extends FormPanel {
target.mutedTalkWarning = mutedWarningCheck.isSelected();
}
private static void selectOrDefault(JComboBox<AudioDevices.Device> combo, String deviceId) {
private static void selectOrDefault(JComboBox<AudioDevice> combo, String deviceId) {
if (deviceId != null && !deviceId.isEmpty()) {
for (int i = 0; i < combo.getItemCount(); i++) {
if (deviceId.equals(combo.getItemAt(i).id())) {
@@ -170,8 +171,8 @@ final class DevicesPanel extends FormPanel {
combo.setSelectedIndex(0);
}
private static String comboValue(JComboBox<AudioDevices.Device> combo) {
AudioDevices.Device d = (AudioDevices.Device) combo.getSelectedItem();
private static String comboValue(JComboBox<AudioDevice> combo) {
AudioDevice d = (AudioDevice) combo.getSelectedItem();
return d == null ? "" : d.id();
}
}

View File

@@ -834,7 +834,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
private void showSettings() {
// Device and voice-gating changes apply to the capturing connection and the
// playback of the visible one; the rest pick the new settings up on connect.
SettingsDialog dlg = new SettingsDialog(this, settings,
SettingsDialog dlg = new SettingsDialog(this, settings, audio,
micTab == null ? null : micTab.connection().getMicrophone(),
selected == null ? null : selected.connection().getPlayback(),
sounds, hotkeys,

View File

@@ -1,9 +1,8 @@
package com.ts3client.ui;
import com.ts3client.audio.AudioBackend;
import com.ts3client.audio.AudioPlayback;
import com.ts3client.audio.VoiceInput;
import com.ts3client.audio.desktop.AudioDevices;
import com.ts3client.audio.desktop.AudioPlayback;
import com.ts3client.audio.desktop.DesktopVoiceInput;
import com.ts3client.config.Settings;
import javax.swing.SwingUtilities;
@@ -13,7 +12,7 @@ import java.util.function.Consumer;
/**
* Drives the settings dialog's microphone test from a real capture chain.
*
* <p>The dialog runs its own {@link DesktopVoiceInput} rather than borrowing the connected
* <p>The dialog runs its own {@link VoiceInput} rather than borrowing the connected
* one, whose listeners belong to the connection. Because it is the same class that feeds
* the server, the level and the gate shown here are exactly what would be transmitted —
* pre-processing, voice detection, hangover and pre-roll included.
@@ -29,7 +28,8 @@ final class MicrophoneTest {
private final Consumer<Double> onLevel;
private final Consumer<Boolean> onTransmitting;
private DesktopVoiceInput mic;
private final AudioBackend audio;
private VoiceInput mic;
private final ArrayBlockingQueue<byte[]> loopbackQueue =
new ArrayBlockingQueue<>(LOOPBACK_QUEUE_FRAMES);
@@ -38,7 +38,8 @@ final class MicrophoneTest {
private Thread loopbackThread;
private String outputDevice = "";
MicrophoneTest(Consumer<Double> onLevel, Consumer<Boolean> onTransmitting) {
MicrophoneTest(AudioBackend audio, Consumer<Double> onLevel, Consumer<Boolean> onTransmitting) {
this.audio = audio;
this.onLevel = onLevel;
this.onTransmitting = onTransmitting;
}
@@ -55,7 +56,7 @@ final class MicrophoneTest {
*/
boolean start(Settings settings) {
stop();
DesktopVoiceInput input = new DesktopVoiceInput(settings);
VoiceInput input = audio.createInput(settings);
input.setLevelListener(db -> SwingUtilities.invokeLater(() -> onLevel.accept(db)));
input.setTalkListener(talking -> SwingUtilities.invokeLater(() -> onTransmitting.accept(talking)));
input.setMonitorListener(this::enqueueForLoopback);
@@ -133,7 +134,7 @@ final class MicrophoneTest {
try {
// The monitored frames are mono for voice; ask for a matching line so no
// channel juggling is needed, and up-mix only if the device insists on stereo.
line = AudioDevices.openPlayback(outputDevice, 1);
line = audio.io().openPlayback(outputDevice, 1);
line.start();
int channels = line.channels();
while (loopbackRunning) {

View File

@@ -1,6 +1,7 @@
package com.ts3client.ui;
import com.formdev.flatlaf.util.UIScale;
import com.ts3client.audio.AudioBackend;
import com.ts3client.audio.OpusParameters;
import com.ts3client.audio.VoiceInput;
import com.ts3client.audio.VoiceOutput;
@@ -39,7 +40,7 @@ public final class SettingsDialog extends JDialog {
private final ClientVersionPanel clientVersionPanel;
private final ContactsPanel contactsPanel;
public SettingsDialog(Frame owner, Settings settings,
public SettingsDialog(Frame owner, Settings settings, AudioBackend audio,
VoiceInput liveMic, VoiceOutput livePlayback,
SoundNotifier sounds, HotkeyService hotkeys, Runnable onApply) {
super(owner, "Options", true);
@@ -53,9 +54,9 @@ public final class SettingsDialog extends JDialog {
hotkeysPanel = new HotkeysPanel(hotkeys);
clientVersionPanel = new ClientVersionPanel(settings);
contactsPanel = new ContactsPanel(settings);
devicesPanel = new DevicesPanel(settings, livePlayback,
devicesPanel = new DevicesPanel(settings, audio.io(), livePlayback,
this::applyLive, this::restartTest, this::setTestOutputDevice);
voiceActivationPanel = new VoiceActivationPanel(settings, liveMic, hotkeys,
voiceActivationPanel = new VoiceActivationPanel(settings, audio, liveMic, hotkeys,
hotkeysPanel, this::audioSnapshot);
JTabbedPane tabs = new JTabbedPane();

View File

@@ -1,6 +1,7 @@
package com.ts3client.ui;
import com.formdev.flatlaf.util.UIScale;
import com.ts3client.audio.AudioBackend;
import com.ts3client.audio.InputLevel;
import com.ts3client.audio.OpusParameters;
import com.ts3client.audio.VoiceInput;
@@ -42,7 +43,7 @@ final class VoiceActivationPanel extends FormPanel {
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 MicrophoneTest micTest;
private final JRadioButton vadRadio;
private final JRadioButton pttRadio;
@@ -62,10 +63,11 @@ final class VoiceActivationPanel extends FormPanel {
private final JCheckBox fecCheck;
private final JCheckBox musicCheck;
VoiceActivationPanel(Settings settings, VoiceInput liveMic, HotkeyService hotkeys,
VoiceActivationPanel(Settings settings, AudioBackend audio, VoiceInput liveMic, HotkeyService hotkeys,
HotkeysPanel hotkeysPanel, Supplier<Settings> audioSnapshot) {
this.settings = settings;
this.liveMic = liveMic;
this.micTest = new MicrophoneTest(audio, this::onTestLevel, this::onTestTalking);
this.hotkeys = hotkeys;
this.hotkeysPanel = hotkeysPanel;
this.audioSnapshot = audioSnapshot;