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:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user