Add a PipeWire audio backend and stereo capture/playback

Capture and playback now go through AudioCapture/AudioPlayback interfaces
with two implementations: PipeWire, used for the system default and for
every pw: device when a session is reachable, and Java Sound for the raw
ALSA devices and other platforms. The PipeWire binding is FFM-based
(PipeWireLibrary, PipeWireSession, PipeWireStream, SpaPod) and enumerates
the graph's real devices, so each stream shows up separately in volume
mixers.

Lines are opened with a negotiated channel count instead of a fixed mono
format: OPUS_MUSIC transmits the stereo capture as-is, OPUS_VOICE keeps
transmitting the mono downmix so the pre-processing chain and VAD still
see a single channel. Java Sound enumeration asks only for "a mixer that
can capture/play" so the PipeWire and PulseAudio ALSA plugins are no
longer hidden by their narrow format lists.

The Java Sound classes are renamed to Desktop* to match what they now
are, and JUnit plus a SpaPod round-trip test are wired into the build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 05:30:46 +00:00
parent d5018729d8
commit 0333b92ce5
23 changed files with 2056 additions and 242 deletions

View File

@@ -1,7 +1,7 @@
package com.ts3client.ui;
import com.ts3client.audio.AudioBackend;
import com.ts3client.audio.desktop.JavaSoundAudioBackend;
import com.ts3client.audio.desktop.DesktopAudioBackend;
import com.ts3client.config.Bookmark;
import com.ts3client.config.Bookmarks;
import com.ts3client.config.IdentityStore;
@@ -49,7 +49,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
private final Settings settings;
private final Bookmarks bookmarks = Bookmarks.load();
private final IdentityStore identities;
private final AudioBackend audio = new JavaSoundAudioBackend();
private final AudioBackend audio = new DesktopAudioBackend();
private final List<ServerTab> tabs = new ArrayList<>();
private final ServerTabPane tabPane = new ServerTabPane(this);

View File

@@ -3,10 +3,10 @@ package com.ts3client.ui;
import com.ts3client.audio.OpusParameters;
import com.ts3client.audio.VoiceInput;
import com.ts3client.audio.VoiceOutput;
import com.ts3client.audio.desktop.AudioCapture;
import com.ts3client.audio.desktop.AudioDevices;
import com.ts3client.config.Settings;
import javax.sound.sampled.TargetDataLine;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.ButtonGroup;
@@ -42,8 +42,8 @@ public final class SettingsDialog extends JDialog {
private final VoiceOutput livePlayback;
private final Runnable onApply;
private JComboBox<String> inputCombo;
private JComboBox<String> outputCombo;
private JComboBox<AudioDevices.Device> inputCombo;
private JComboBox<AudioDevices.Device> outputCombo;
private JSlider inputGain;
private JSlider outputVol;
private JCheckBox denoiseCheck;
@@ -123,16 +123,20 @@ public final class SettingsDialog extends JDialog {
p.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
GridBagConstraints c = gbc();
List<String> ins = AudioDevices.inputDeviceNames();
List<String> outs = AudioDevices.outputDeviceNames();
ins.add(0, "(System default)");
outs.add(0, "(System default)");
List<AudioDevices.Device> ins = AudioDevices.inputDevices();
List<AudioDevices.Device> outs = AudioDevices.outputDevices();
inputCombo = new JComboBox<>(ins.toArray(new String[0]));
outputCombo = new JComboBox<>(outs.toArray(new String[0]));
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);
int row = 0;
addRow(p, c, row++, new JLabel("Capture device (microphone):"), inputCombo);
addRow(p, c, row++, new JLabel("Playback device (speakers):"), outputCombo);
@@ -308,7 +312,11 @@ public final class SettingsDialog extends JDialog {
vbrCheck = new JCheckBox("Variable bitrate (VBR)", settings.vbr);
fecCheck = new JCheckBox("Forward error correction (FEC)", settings.fec);
musicCheck = new JCheckBox("Music codec (higher fidelity)", settings.music);
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());
@@ -505,25 +513,26 @@ public final class SettingsDialog extends JDialog {
private void meterLoop() {
String device = comboValue(inputCombo);
TargetDataLine line = null;
AudioCapture line = null;
try {
line = AudioDevices.openCapture(device);
line = AudioDevices.openCapture(device, AudioDevices.MAX_CHANNELS);
line.start();
int frame = AudioDevices.FRAME_SIZE;
byte[] buf = new byte[frame * 2];
int channels = line.channels();
byte[] buf = new byte[frame * 2 * channels];
double gain = (inputGain != null ? inputGain.getValue() / 100.0 : 1.0);
while (meterRunning) {
int read = 0;
while (read < buf.length) {
int n = line.read(buf, read, buf.length - read);
if (n <= 0) break;
read += n;
}
if (read < buf.length) break;
if (line.read(buf, 0, buf.length) < buf.length) break;
// Metering follows the capture chain: level is taken off the downmix.
double sumSq = 0;
for (int i = 0; i < frame; i++) {
short s = (short) ((buf[2 * i + 1] << 8) | (buf[2 * i] & 0xFF));
double f = s / 32768.0 * gain;
double mono = 0;
for (int c = 0; c < channels; c++) {
int k = i * channels + c;
short s = (short) ((buf[2 * k + 1] << 8) | (buf[2 * k] & 0xFF));
mono += s / 32768.0;
}
double f = mono / channels * gain;
sumSq += f * f;
}
double rms = Math.sqrt(sumSq / frame);
@@ -533,13 +542,7 @@ public final class SettingsDialog extends JDialog {
}
} catch (Exception ignored) {
} finally {
if (line != null) {
try {
line.stop();
line.close();
} catch (Exception ignored) {
}
}
if (line != null) line.close();
}
}
@@ -564,24 +567,20 @@ public final class SettingsDialog extends JDialog {
p.add(field, c);
}
private static void selectOrDefault(JComboBox<String> combo, String value) {
if (value == null || value.isEmpty()) {
combo.setSelectedIndex(0);
return;
}
for (int i = 0; i < combo.getItemCount(); i++) {
if (value.equals(combo.getItemAt(i))) {
combo.setSelectedIndex(i);
return;
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<String> combo) {
int idx = combo.getSelectedIndex();
if (idx <= 0) return "";
Object v = combo.getSelectedItem();
return v == null ? "" : v.toString();
private static String comboValue(JComboBox<AudioDevices.Device> combo) {
AudioDevices.Device d = (AudioDevices.Device) combo.getSelectedItem();
return d == null ? "" : d.id();
}
}