Modelled on {@link javax.sound.sampled.TargetDataLine} so the capture pipeline does + * not care whether the audio comes from PipeWire or Java Sound. + */ +public interface AudioCapture extends AutoCloseable { + + /** Channel count actually negotiated with the device. */ + int channels(); + + /** Begins capturing; audio read before this call is not delivered. */ + void start(); + + /** + * Blocks until {@code length} bytes are captured. Returns fewer bytes only when the + * line is closing or the calling thread was interrupted. + */ + int read(byte[] buffer, int offset, int length); + + @Override + void close(); +} diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/AudioDevices.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/AudioDevices.java index f34674e..cc405d9 100644 --- a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/AudioDevices.java +++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/AudioDevices.java @@ -1,9 +1,12 @@ package com.ts3client.audio.desktop; +import com.ts3client.audio.desktop.pipewire.PipeWire; + import javax.sound.sampled.AudioFormat; import javax.sound.sampled.AudioSystem; import javax.sound.sampled.DataLine; import javax.sound.sampled.Line; +import javax.sound.sampled.LineUnavailableException; import javax.sound.sampled.Mixer; import javax.sound.sampled.SourceDataLine; import javax.sound.sampled.TargetDataLine; @@ -11,74 +14,209 @@ import java.util.ArrayList; import java.util.List; /** - * Helpers for enumerating and opening capture/playback lines by mixer name. + * Enumerates audio devices and opens capture/playback lines on them. * - *
The TS3 protocol uses 48 kHz, 16-bit, mono/stereo signed little-endian - * PCM for Opus. We standardise on that format everywhere. + *
The TS3 protocol uses 48 kHz 16-bit signed little-endian PCM for Opus, mono for + * {@code OPUS_VOICE} and stereo for {@code OPUS_MUSIC}, so the channel count is + * negotiated per line rather than fixed. + * + *
Two backends sit behind {@link AudioCapture} / {@link AudioPlayback}: + *
Java Sound enumeration deliberately asks only for "a mixer that can do
+ * capture/playback" instead of "a mixer that can do exactly this format": the PipeWire
+ * and PulseAudio ALSA plugins advertise a narrow set of formats (typically stereo only),
+ * so a format-specific query hides them entirely. The desired format is negotiated in
+ * {@link #openCapture} / {@link #openPlayback}, which fall back through the supported
+ * channel counts.
*/
public final class AudioDevices {
public static final int SAMPLE_RATE = 48_000;
public static final int FRAME_SIZE = 960; // 20 ms @ 48 kHz
- public static final AudioFormat CAPTURE_FORMAT =
- new AudioFormat(SAMPLE_RATE, 16, 1, true, false); // mono capture
- public static final AudioFormat PLAYBACK_FORMAT =
- new AudioFormat(SAMPLE_RATE, 16, 1, true, false); // mono playback
+ public static final int MAX_CHANNELS = 2;
+
+ /** Buffer size, in 20 ms frames, requested when opening a Java Sound line. */
+ private static final int BUFFER_FRAMES = 8;
+
+ /** Marks a device id as a PipeWire node name rather than a Java Sound mixer name. */
+ private static final String PIPEWIRE_PREFIX = "pw:";
+
+ /** A selectable audio device. {@code id} is what gets persisted in the settings. */
+ public record Device(String id, String label) {
+ /** The implicit entry that lets the platform (PipeWire, Pulse, ALSA) choose. */
+ public static final Device DEFAULT = new Device("", "(System default)");
+
+ @Override
+ public String toString() {
+ return label;
+ }
+ }
private AudioDevices() {
}
- /** Names of mixers that can provide microphone (capture) lines. */
- public static List Modelled on {@link javax.sound.sampled.SourceDataLine} so the playback pipeline does
+ * not care whether the audio goes to PipeWire or Java Sound.
+ */
+public interface AudioPlayback extends AutoCloseable {
+
+ /** Channel count actually negotiated with the device. */
+ int channels();
+
+ /** Begins playing whatever is written from now on. */
+ void start();
+
+ /** Blocks until the audio has been queued for playback. */
+ void write(byte[] buffer, int offset, int length);
+
+ /** Blocks until everything already written has been played out. */
+ void drain();
+
+ @Override
+ void close();
+}
diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopAudioBackend.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopAudioBackend.java
new file mode 100644
index 0000000..4028bdc
--- /dev/null
+++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopAudioBackend.java
@@ -0,0 +1,42 @@
+package com.ts3client.audio.desktop;
+
+import com.ts3client.audio.AudioBackend;
+import com.ts3client.audio.VoiceInput;
+import com.ts3client.audio.VoiceOutput;
+import com.ts3client.audio.desktop.pipewire.PipeWire;
+import com.ts3client.config.Settings;
+
+/**
+ * Desktop audio backend: PipeWire for capture/playback where it is running, Java Sound
+ * everywhere else, and native Opus (via the FFM API) for the codec.
+ */
+public final class DesktopAudioBackend implements AudioBackend {
+
+ @Override
+ public VoiceInput createInput(Settings settings) {
+ return new DesktopVoiceInput(settings);
+ }
+
+ @Override
+ public VoiceOutput createOutput(Settings settings) {
+ return new DesktopVoiceOutput(settings.outputDevice);
+ }
+
+ @Override
+ public String description() {
+ return codec() + ", " + audioSystem();
+ }
+
+ private static String codec() {
+ try {
+ return "Opus " + Opus.getVersionString();
+ } catch (Throwable t) {
+ return "Opus (native library unavailable)";
+ }
+ }
+
+ private static String audioSystem() {
+ String pipeWire = PipeWire.version();
+ return pipeWire != null ? "PipeWire " + pipeWire : "Java Sound";
+ }
+}
diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/JavaSoundVoiceInput.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopVoiceInput.java
similarity index 62%
rename from ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/JavaSoundVoiceInput.java
rename to ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopVoiceInput.java
index efca4c0..d3db47e 100644
--- a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/JavaSoundVoiceInput.java
+++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopVoiceInput.java
@@ -7,21 +7,26 @@ import com.ts3client.audio.SpeechDetector;
import com.ts3client.audio.VoiceInput;
import com.ts3client.config.Settings;
-import javax.sound.sampled.TargetDataLine;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
/**
- * Java Sound {@link VoiceInput}: captures the microphone, applies voice-activation
+ * Desktop {@link VoiceInput}: captures the microphone, applies voice-activation
* or push-to-talk gating, and Opus-encodes 20 ms frames.
*
+ * The capture line comes from {@link AudioDevices}, which picks PipeWire or Java
+ * Sound; it is opened in stereo when the device offers it. Voice
+ * ({@code OPUS_VOICE}) is transmitted mono, from the downmix, so the pre-processing
+ * chain and VAD see a single channel; the music codec ({@code OPUS_MUSIC}) transmits
+ * the stereo capture as-is.
+ *
* A dedicated capture thread fills a small packet queue while ts3j polls
* {@link #isReady()} / {@link #provide()} on its own timer, decoupling capture from
* network sending. When the gate closes and the queue drains {@link #isReady()}
* returns {@code false}, prompting ts3j to emit the terminating empty voice packet.
*/
-public final class JavaSoundVoiceInput implements VoiceInput {
+public final class DesktopVoiceInput implements VoiceInput {
private static final int HANGOVER_FRAMES = 15; // ~300 ms of tail after level drops
@@ -49,16 +54,19 @@ public final class JavaSoundVoiceInput implements VoiceInput {
private final Object encoderLock = new Object();
private volatile OpusParameters params;
+ private OpusParameters appliedParams;
private int encoderApplication = -1;
+ private volatile int encoderChannels = 1;
+ private volatile int captureChannels = 1;
private Thread captureThread;
- private TargetDataLine line;
+ private AudioCapture line;
private OpusEncoder encoder;
private int hangover;
private boolean lastTransmitting;
- public JavaSoundVoiceInput(Settings settings) {
+ public DesktopVoiceInput(Settings settings) {
this.deviceName = settings.inputDevice;
this.mode = settings.inputMode;
this.vadMode = settings.vadMode;
@@ -67,7 +75,7 @@ public final class JavaSoundVoiceInput implements VoiceInput {
this.vadOverPtt = settings.vadOverPtt;
this.inputGain = settings.inputVolume;
this.params = OpusParameters.from(settings);
- this.codec = params.music ? CodecType.OPUS_MUSIC : CodecType.OPUS_VOICE;
+ this.codec = codecFor(params);
enhancer.setNoiseSuppression(settings.denoise);
enhancer.setDenoiserLevel(settings.denoiserLevel);
enhancer.setTypingAttenuation(settings.typingAttenuation);
@@ -117,25 +125,16 @@ public final class JavaSoundVoiceInput implements VoiceInput {
enhancer.setAgc(enabled);
}
+ /**
+ * Stages new encoder settings; the capture thread picks them up on the next frame
+ * (or {@link #start()} applies them directly when idle).
+ */
public void setOpusParameters(OpusParameters p) {
this.params = p;
- this.codec = p.music ? CodecType.OPUS_MUSIC : CodecType.OPUS_VOICE;
synchronized (encoderLock) {
- if (encoder == null) return;
- int desiredApplication = applicationFor(p);
- if (desiredApplication != encoderApplication) {
- // The Opus application (VOIP vs AUDIO) can only be chosen at
- // creation, so switching voice<->music requires a new encoder.
- OpusEncoder replacement = new OpusEncoder(
- AudioDevices.SAMPLE_RATE, AudioDevices.FRAME_SIZE, 1, desiredApplication);
- configureEncoder(replacement, p);
- OpusEncoder previous = encoder;
- encoder = replacement;
- encoderApplication = desiredApplication;
- previous.close();
- } else {
- configureEncoder(encoder, p);
- }
+ // While capturing, the codec flag flips together with the encoder swap so
+ // no packet is ever tagged with a codec it wasn't encoded for.
+ if (encoder == null) this.codec = codecFor(p);
}
}
@@ -143,6 +142,40 @@ public final class JavaSoundVoiceInput implements VoiceInput {
return p.music ? Opus.OPUS_APPLICATION_AUDIO : Opus.OPUS_APPLICATION_VOIP;
}
+ private static CodecType codecFor(OpusParameters p) {
+ return p.music ? CodecType.OPUS_MUSIC : CodecType.OPUS_VOICE;
+ }
+
+ /**
+ * Channels to transmit: TeamSpeak's {@code OPUS_MUSIC} stream is stereo,
+ * {@code OPUS_VOICE} is mono. A mono-only capture device caps this at one.
+ */
+ private int channelsFor(OpusParameters p) {
+ return (p.music && captureChannels >= 2) ? 2 : 1;
+ }
+
+ /** Creates or reconfigures the encoder to match {@code p}. Call under {@link #encoderLock}. */
+ private void applyParams(OpusParameters p) {
+ int application = applicationFor(p);
+ int channels = channelsFor(p);
+ // Application (VOIP vs AUDIO) and channel count are fixed at creation, so
+ // switching voice<->music means building a new encoder.
+ if (encoder == null || application != encoderApplication || channels != encoderChannels) {
+ OpusEncoder replacement = new OpusEncoder(
+ AudioDevices.SAMPLE_RATE, AudioDevices.FRAME_SIZE, channels, application);
+ configureEncoder(replacement, p);
+ OpusEncoder previous = encoder;
+ encoder = replacement;
+ encoderApplication = application;
+ encoderChannels = channels;
+ if (previous != null) previous.close();
+ } else {
+ configureEncoder(encoder, p);
+ }
+ codec = codecFor(p);
+ appliedParams = p;
+ }
+
private static void configureEncoder(OpusEncoder enc, OpusParameters p) {
enc.setBitrate(p.bitrate);
enc.setComplexity(p.complexity);
@@ -173,13 +206,10 @@ public final class JavaSoundVoiceInput implements VoiceInput {
public synchronized void start() {
if (running.get()) return;
try {
- line = AudioDevices.openCapture(deviceName);
- OpusParameters p = params;
+ line = AudioDevices.openCapture(deviceName, AudioDevices.MAX_CHANNELS);
+ captureChannels = line.channels();
synchronized (encoderLock) {
- encoderApplication = applicationFor(p);
- encoder = new OpusEncoder(
- AudioDevices.SAMPLE_RATE, AudioDevices.FRAME_SIZE, 1, encoderApplication);
- configureEncoder(encoder, p);
+ applyParams(params);
}
} catch (Throwable t) {
cleanup();
@@ -206,11 +236,7 @@ public final class JavaSoundVoiceInput implements VoiceInput {
private void cleanup() {
if (line != null) {
- try {
- line.stop();
- line.close();
- } catch (Exception ignored) {
- }
+ line.close();
line = null;
}
synchronized (encoderLock) {
@@ -221,32 +247,30 @@ public final class JavaSoundVoiceInput implements VoiceInput {
}
encoder = null;
encoderApplication = -1;
+ appliedParams = null;
}
}
}
private void captureLoop() {
final int frameSamples = AudioDevices.FRAME_SIZE;
- final byte[] buf = new byte[frameSamples * 2];
- final float[] pcm = new float[frameSamples];
+ final int channels = captureChannels;
+ final byte[] buf = new byte[frameSamples * 2 * channels];
+ final float[] pcm = new float[frameSamples * channels]; // interleaved capture
+ // Analysis (level, VAD) always runs on a mono downmix; with a mono device that
+ // is the capture buffer itself, so nothing is copied.
+ final float[] mono = (channels == 1) ? pcm : new float[frameSamples];
- line.start();
+ // Held locally so a concurrent stop() closing the line cannot null it mid-loop.
+ final AudioCapture capture = line;
+ capture.start();
while (running.get()) {
- int read = 0;
- try {
- while (read < buf.length) {
- int n = line.read(buf, read, buf.length - read);
- if (n <= 0) break;
- read += n;
- }
- } catch (Exception e) {
- break;
- }
- if (read < buf.length) continue;
+ // A short read only happens once the line is closing, or on interruption.
+ if (capture.read(buf, 0, buf.length) < buf.length) break;
// 16-bit LE -> float, with input gain
- for (int i = 0; i < frameSamples; i++) {
+ for (int i = 0; i < pcm.length; i++) {
int lo = buf[2 * i] & 0xFF;
int hi = buf[2 * i + 1];
short s = (short) ((hi << 8) | lo);
@@ -255,36 +279,50 @@ public final class JavaSoundVoiceInput implements VoiceInput {
else if (f < -1f) f = -1f;
pcm[i] = f;
}
-
- // Denoise / typing attenuation feed the level meter, VAD and encoder alike.
- enhancer.process(pcm, frameSamples);
-
- double sumSq = 0;
- for (int i = 0; i < frameSamples; i++) {
- sumSq += (double) pcm[i] * pcm[i];
- }
- double rms = Math.sqrt(sumSq / frameSamples);
- double db = (rms <= 1e-9) ? -100.0 : 20.0 * Math.log10(rms);
- Consumer Created as mono at 48 kHz; Opus transparently down-mixes stereo streams
- * (e.g. music-bot audio) to the requested channel count, so a single mono decoder
- * copes with both {@code OPUS_VOICE} and {@code OPUS_MUSIC} payloads.
+ * Fixed at 48 kHz. The channel count should match the stream being decoded
+ * (mono for {@code OPUS_VOICE}, stereo for {@code OPUS_MUSIC}); Opus will up-/down-mix
+ * a mismatched stream, which costs the stereo image.
*/
public final class OpusDecoder implements AutoCloseable {
diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/pipewire/PcmRing.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/pipewire/PcmRing.java
new file mode 100644
index 0000000..ff48d85
--- /dev/null
+++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/pipewire/PcmRing.java
@@ -0,0 +1,141 @@
+package com.ts3client.audio.desktop.pipewire;
+
+import java.lang.foreign.MemorySegment;
+
+/**
+ * Byte ring buffer between the application threads and PipeWire's loop thread.
+ *
+ * The stream callbacks must never block — a late buffer is a glitch for the whole
+ * graph — so the loop side ({@link #put}/{@link #take}) always returns immediately and
+ * absorbs the mismatch: a capture overrun drops the oldest audio, a playback underrun is
+ * reported as a short read and padded with silence by the caller. The application side
+ * ({@link #write}/{@link #read}) blocks instead, which is what {@code SourceDataLine} and
+ * {@code TargetDataLine} do and what the voice pipeline expects.
+ */
+final class PcmRing {
+
+ private final byte[] data;
+ private final MemorySegment view;
+ private int start; // index of the oldest byte
+ private int count; // bytes currently held
+ private boolean closed;
+
+ PcmRing(int capacity) {
+ this.data = new byte[capacity];
+ this.view = MemorySegment.ofArray(data);
+ }
+
+ int capacity() {
+ return data.length;
+ }
+
+ synchronized int available() {
+ return count;
+ }
+
+ synchronized void clear() {
+ start = 0;
+ count = 0;
+ notifyAll();
+ }
+
+ /** Unblocks every waiter; subsequent reads report end-of-stream and writes are dropped. */
+ synchronized void close() {
+ closed = true;
+ notifyAll();
+ }
+
+ synchronized boolean isClosed() {
+ return closed;
+ }
+
+ // ---- loop-thread side (never blocks) ----
+
+ /** Appends up to {@code length} bytes, dropping the oldest audio if that overruns. */
+ synchronized void put(MemorySegment source, long offset, int length) {
+ if (closed) return;
+ int overflow = count + length - data.length;
+ if (overflow > 0) skip(overflow);
+ int copied = Math.min(length, data.length);
+ long from = offset + (length - copied);
+ int end = (start + count) % data.length;
+ int firstChunk = Math.min(copied, data.length - end);
+ MemorySegment.copy(source, from, view, end, firstChunk);
+ MemorySegment.copy(source, from + firstChunk, view, 0, copied - firstChunk);
+ count += copied;
+ notifyAll();
+ }
+
+ /** Removes up to {@code length} bytes into {@code destination}; returns how many. */
+ synchronized int take(MemorySegment destination, long offset, int length) {
+ int copied = Math.min(length, count);
+ int firstChunk = Math.min(copied, data.length - start);
+ MemorySegment.copy(view, start, destination, offset, firstChunk);
+ MemorySegment.copy(view, 0, destination, offset + firstChunk, copied - firstChunk);
+ skip(copied);
+ notifyAll();
+ return copied;
+ }
+
+ // ---- application side (blocks) ----
+
+ /**
+ * Blocks until {@code length} bytes have been read, the ring is closed, or the calling
+ * thread is interrupted. Returns the number of bytes actually read.
+ */
+ synchronized int read(byte[] destination, int offset, int length) {
+ MemorySegment target = MemorySegment.ofArray(destination);
+ int read = 0;
+ while (read < length) {
+ if (count == 0) {
+ if (closed || !await()) break;
+ continue;
+ }
+ read += take(target, offset + read, length - read);
+ }
+ return read;
+ }
+
+ /**
+ * Blocks until {@code length} bytes have been queued, the ring is closed, or the
+ * calling thread is interrupted. Returns the number of bytes actually written.
+ */
+ synchronized int write(byte[] source, int offset, int length) {
+ MemorySegment target = MemorySegment.ofArray(source);
+ int written = 0;
+ while (written < length && !closed) {
+ int free = data.length - count;
+ if (free == 0) {
+ if (!await()) break;
+ continue;
+ }
+ int chunk = Math.min(free, length - written);
+ put(target, offset + written, chunk);
+ written += chunk;
+ }
+ return written;
+ }
+
+ /** Blocks until the ring has drained (or was closed / the wait was interrupted). */
+ synchronized void drain() {
+ while (count > 0 && !closed) {
+ if (!await()) return;
+ }
+ }
+
+ /** Waits for a state change; {@code false} when interrupted. */
+ private boolean await() {
+ try {
+ wait(100);
+ return true;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return false;
+ }
+ }
+
+ private void skip(int bytes) {
+ start = (start + bytes) % data.length;
+ count -= bytes;
+ }
+}
diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/pipewire/PipeWire.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/pipewire/PipeWire.java
new file mode 100644
index 0000000..dc25deb
--- /dev/null
+++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/pipewire/PipeWire.java
@@ -0,0 +1,78 @@
+package com.ts3client.audio.desktop.pipewire;
+
+import com.ts3client.audio.desktop.AudioCapture;
+import com.ts3client.audio.desktop.AudioPlayback;
+
+import java.util.List;
+
+/**
+ * PipeWire audio backend.
+ *
+ * On a PipeWire desktop this replaces Java Sound entirely: devices are enumerated
+ * from the graph — with their real names, including Bluetooth and virtual devices,
+ * which Java Sound's ALSA-only provider never sees — and capture and playback run as
+ * ordinary {@code pw_stream}s that appear individually in volume mixers.
+ *
+ * Everything here is a no-op on a machine without PipeWire: {@link #isAvailable()}
+ * reports {@code false} and {@link com.ts3client.audio.desktop.AudioDevices} falls back
+ * to Java Sound.
+ */
+public final class PipeWire {
+
+ /** A selectable PipeWire device. {@code name} is the node name used to target it. */
+ public record Node(String name, String description, boolean sink) {
+ }
+
+ private PipeWire() {
+ }
+
+ /** {@code true} when libpipewire loaded and a session is reachable. */
+ public static boolean isAvailable() {
+ return PipeWireSession.get() != null;
+ }
+
+ /** The version of the loaded libpipewire, or {@code null} when it is not available. */
+ public static String version() {
+ if (!isAvailable()) return null;
+ try {
+ return PipeWireLibrary.libraryVersion();
+ } catch (Throwable t) {
+ return null;
+ }
+ }
+
+ /** The audio sinks and sources currently in the graph; empty without PipeWire. */
+ public static List This class is the only place that knows about the C ABI: the exported entry points,
+ * the SPA/PipeWire constants, and the offsets of the few structures that are read or
+ * written directly. Everything above it works with Java types.
+ *
+ * Loading is lazy and failure is expected: on a machine without PipeWire (or without
+ * a session running) {@link #isAvailable()} returns {@code false} and the caller falls
+ * back to Java Sound. The class initialiser is what fails, so both the first
+ * {@link ExceptionInInitializerError} and the later {@link NoClassDefFoundError} are
+ * caught.
+ *
+ * Constants and structure offsets below were taken from the pipewire 1.4 headers and
+ * are part of the frozen 0.3 ABI.
+ */
+final class PipeWireLibrary {
+
+ private PipeWireLibrary() {
+ }
+
+ static final AddressLayout PTR = ValueLayout.ADDRESS;
+ static final ValueLayout.OfInt INT = ValueLayout.JAVA_INT;
+ static final ValueLayout.OfLong LONG = ValueLayout.JAVA_LONG;
+ static final ValueLayout.OfBoolean BOOL = ValueLayout.JAVA_BOOLEAN;
+
+ // ---- enum pw_direction ----
+ static final int PW_DIRECTION_INPUT = 0;
+ static final int PW_DIRECTION_OUTPUT = 1;
+
+ // ---- well-known object ids ----
+ static final int PW_ID_CORE = 0;
+ static final int PW_ID_ANY = 0xFFFFFFFF;
+
+ // ---- interface versions we ask for / implement ----
+ static final int PW_VERSION_REGISTRY = 3;
+ static final int PW_VERSION_CORE_EVENTS = 1;
+ static final int PW_VERSION_REGISTRY_EVENTS = 0;
+ static final int PW_VERSION_STREAM_EVENTS = 2;
+
+ // ---- enum pw_stream_flags ----
+ static final int PW_STREAM_FLAG_AUTOCONNECT = 1 << 0;
+ static final int PW_STREAM_FLAG_INACTIVE = 1 << 1;
+ static final int PW_STREAM_FLAG_MAP_BUFFERS = 1 << 2;
+
+ // ---- enum pw_stream_state ----
+ static final int PW_STREAM_STATE_ERROR = -1;
+ static final int PW_STREAM_STATE_UNCONNECTED = 0;
+ static final int PW_STREAM_STATE_CONNECTING = 1;
+ static final int PW_STREAM_STATE_PAUSED = 2;
+ static final int PW_STREAM_STATE_STREAMING = 3;
+
+ // ---- property keys (pipewire/keys.h) ----
+ static final String KEY_APP_NAME = "application.name";
+ static final String KEY_MEDIA_TYPE = "media.type";
+ static final String KEY_MEDIA_CATEGORY = "media.category";
+ static final String KEY_MEDIA_ROLE = "media.role";
+ static final String KEY_MEDIA_CLASS = "media.class";
+ static final String KEY_NODE_NAME = "node.name";
+ static final String KEY_NODE_DESCRIPTION = "node.description";
+ static final String KEY_NODE_NICK = "node.nick";
+ static final String KEY_NODE_LATENCY = "node.latency";
+ static final String KEY_NODE_RATE = "node.rate";
+ static final String KEY_TARGET_OBJECT = "target.object";
+
+ static final String TYPE_INTERFACE_NODE = "PipeWire:Interface:Node";
+
+ // ---- struct offsets (x86-64/aarch64 LP64; all fields are naturally aligned) ----
+
+ /** {@code struct spa_interface { const char *type; uint32_t version; struct spa_callbacks cb; }} */
+ private static final long SPA_INTERFACE_SIZE = 32;
+ private static final long SPA_INTERFACE_CB_FUNCS = 16;
+ private static final long SPA_INTERFACE_CB_DATA = 24;
+
+ /** {@code struct spa_dict { uint32_t flags, n_items; const struct spa_dict_item *items; }} */
+ static final long SPA_DICT_SIZE = 16;
+ static final long SPA_DICT_N_ITEMS = 4;
+ static final long SPA_DICT_ITEMS = 8;
+
+ /** {@code struct spa_dict_item { const char *key, *value; }} */
+ static final long SPA_DICT_ITEM_SIZE = 16;
+ static final long SPA_DICT_ITEM_VALUE = 8;
+
+ /** {@code struct pw_buffer { struct spa_buffer *buffer; void *user_data; uint64_t size, requested, time; }} */
+ static final long PW_BUFFER_BUFFER = 0;
+ static final long PW_BUFFER_SIZE = 16;
+ static final long PW_BUFFER_REQUESTED = 24;
+ static final long PW_BUFFER_SIZEOF = 40;
+
+ /** {@code struct spa_buffer { uint32_t n_metas, n_datas; struct spa_meta *metas; struct spa_data *datas; }} */
+ static final long SPA_BUFFER_N_DATAS = 4;
+ static final long SPA_BUFFER_DATAS = 16;
+ static final long SPA_BUFFER_SIZEOF = 24;
+
+ /**
+ * {@code struct spa_data { uint32_t type, flags; int64_t fd; uint32_t mapoffset, maxsize;
+ * void *data; struct spa_chunk *chunk; }}
+ */
+ static final long SPA_DATA_MAXSIZE = 20;
+ static final long SPA_DATA_DATA = 24;
+ static final long SPA_DATA_CHUNK = 32;
+ static final long SPA_DATA_SIZEOF = 40;
+
+ /** {@code struct spa_chunk { uint32_t offset, size; int32_t stride, flags; }} */
+ static final long SPA_CHUNK_OFFSET = 0;
+ static final long SPA_CHUNK_SIZE = 4;
+ static final long SPA_CHUNK_STRIDE = 8;
+ static final long SPA_CHUNK_SIZEOF = 16;
+
+ /** Slot (pointer index) of a method inside {@code struct pw_core_methods}. */
+ static final int CORE_METHOD_ADD_LISTENER = 1;
+ static final int CORE_METHOD_SYNC = 3;
+ static final int CORE_METHOD_GET_REGISTRY = 6;
+
+ /** Slot of a method inside {@code struct pw_registry_methods}. */
+ static final int REGISTRY_METHOD_ADD_LISTENER = 1;
+
+ /** Field offsets in {@code struct pw_core_events} (version at 0, then one pointer each). */
+ static final long CORE_EVENTS_SIZEOF = 80;
+ static final long CORE_EVENTS_DONE = 16;
+
+ /** Field offsets in {@code struct pw_registry_events}. */
+ static final long REGISTRY_EVENTS_SIZEOF = 24;
+ static final long REGISTRY_EVENTS_GLOBAL = 8;
+ static final long REGISTRY_EVENTS_GLOBAL_REMOVE = 16;
+
+ /** Field offsets in {@code struct pw_stream_events}. */
+ static final long STREAM_EVENTS_SIZEOF = 96;
+ static final long STREAM_EVENTS_STATE_CHANGED = 16;
+ static final long STREAM_EVENTS_PROCESS = 64;
+
+ /** {@code struct spa_hook}; over-allocated so a future growth cannot corrupt anything. */
+ static final long SPA_HOOK_SIZEOF = 128;
+
+ static final Linker LINKER = Linker.nativeLinker();
+
+ /** SONAMEs to try, newest packaging first. */
+ private static final String[] LIBRARY_NAMES = {"libpipewire-0.3.so.0", "libpipewire-0.3.so"};
+
+ private static final class Handles {
+ static final SymbolLookup LOOKUP = load();
+
+ static final MethodHandle PW_INIT =
+ downcall("pw_init", FunctionDescriptor.ofVoid(PTR, PTR));
+ static final MethodHandle PW_GET_LIBRARY_VERSION =
+ downcall("pw_get_library_version", FunctionDescriptor.of(PTR));
+
+ static final MethodHandle PW_THREAD_LOOP_NEW =
+ downcall("pw_thread_loop_new", FunctionDescriptor.of(PTR, PTR, PTR));
+ static final MethodHandle PW_THREAD_LOOP_DESTROY =
+ downcall("pw_thread_loop_destroy", FunctionDescriptor.ofVoid(PTR));
+ static final MethodHandle PW_THREAD_LOOP_GET_LOOP =
+ downcall("pw_thread_loop_get_loop", FunctionDescriptor.of(PTR, PTR));
+ static final MethodHandle PW_THREAD_LOOP_START =
+ downcall("pw_thread_loop_start", FunctionDescriptor.of(INT, PTR));
+ static final MethodHandle PW_THREAD_LOOP_STOP =
+ downcall("pw_thread_loop_stop", FunctionDescriptor.ofVoid(PTR));
+ static final MethodHandle PW_THREAD_LOOP_LOCK =
+ downcall("pw_thread_loop_lock", FunctionDescriptor.ofVoid(PTR));
+ static final MethodHandle PW_THREAD_LOOP_UNLOCK =
+ downcall("pw_thread_loop_unlock", FunctionDescriptor.ofVoid(PTR));
+ static final MethodHandle PW_THREAD_LOOP_SIGNAL =
+ downcall("pw_thread_loop_signal", FunctionDescriptor.ofVoid(PTR, BOOL));
+ static final MethodHandle PW_THREAD_LOOP_TIMED_WAIT =
+ downcall("pw_thread_loop_timed_wait", FunctionDescriptor.of(INT, PTR, INT));
+
+ static final MethodHandle PW_CONTEXT_NEW =
+ downcall("pw_context_new", FunctionDescriptor.of(PTR, PTR, PTR, LONG));
+ static final MethodHandle PW_CONTEXT_CONNECT =
+ downcall("pw_context_connect", FunctionDescriptor.of(PTR, PTR, PTR, LONG));
+ static final MethodHandle PW_CONTEXT_DESTROY =
+ downcall("pw_context_destroy", FunctionDescriptor.ofVoid(PTR));
+ static final MethodHandle PW_CORE_DISCONNECT =
+ downcall("pw_core_disconnect", FunctionDescriptor.of(INT, PTR));
+ static final MethodHandle PW_PROXY_DESTROY =
+ downcall("pw_proxy_destroy", FunctionDescriptor.ofVoid(PTR));
+
+ static final MethodHandle PW_PROPERTIES_NEW_DICT =
+ downcall("pw_properties_new_dict", FunctionDescriptor.of(PTR, PTR));
+
+ static final MethodHandle PW_STREAM_NEW_SIMPLE =
+ downcall("pw_stream_new_simple", FunctionDescriptor.of(PTR, PTR, PTR, PTR, PTR, PTR));
+ static final MethodHandle PW_STREAM_CONNECT =
+ downcall("pw_stream_connect", FunctionDescriptor.of(INT, PTR, INT, INT, INT, PTR, INT));
+ static final MethodHandle PW_STREAM_DISCONNECT =
+ downcall("pw_stream_disconnect", FunctionDescriptor.of(INT, PTR));
+ static final MethodHandle PW_STREAM_DESTROY =
+ downcall("pw_stream_destroy", FunctionDescriptor.ofVoid(PTR));
+ static final MethodHandle PW_STREAM_SET_ACTIVE =
+ downcall("pw_stream_set_active", FunctionDescriptor.of(INT, PTR, BOOL));
+ static final MethodHandle PW_STREAM_FLUSH =
+ downcall("pw_stream_flush", FunctionDescriptor.of(INT, PTR, BOOL));
+ static final MethodHandle PW_STREAM_DEQUEUE_BUFFER =
+ downcall("pw_stream_dequeue_buffer", FunctionDescriptor.of(PTR, PTR));
+ static final MethodHandle PW_STREAM_QUEUE_BUFFER =
+ downcall("pw_stream_queue_buffer", FunctionDescriptor.of(INT, PTR, PTR));
+
+ private static SymbolLookup load() {
+ IllegalArgumentException last = null;
+ for (String name : LIBRARY_NAMES) {
+ try {
+ return SymbolLookup.libraryLookup(name, Arena.global());
+ } catch (IllegalArgumentException e) {
+ last = e;
+ }
+ }
+ throw (last != null) ? last : new IllegalArgumentException("libpipewire not found");
+ }
+
+ private static MethodHandle downcall(String symbol, FunctionDescriptor descriptor) {
+ return LINKER.downcallHandle(
+ LOOKUP.find(symbol).orElseThrow(() ->
+ new UnsatisfiedLinkError("libpipewire: unresolved symbol " + symbol)),
+ descriptor);
+ }
+ }
+
+ /** {@code true} when libpipewire could be loaded and every symbol we need resolved. */
+ static boolean isAvailable() {
+ try {
+ return Handles.LOOKUP != null;
+ } catch (Throwable t) {
+ return false;
+ }
+ }
+
+ // ---- library ----
+
+ static void init() {
+ invokeVoid(Handles.PW_INIT, MemorySegment.NULL, MemorySegment.NULL);
+ }
+
+ static String libraryVersion() {
+ MemorySegment version = (MemorySegment) invoke(Handles.PW_GET_LIBRARY_VERSION);
+ return cString(version);
+ }
+
+ // ---- thread loop ----
+
+ static MemorySegment threadLoopNew(MemorySegment name) {
+ return (MemorySegment) invoke(Handles.PW_THREAD_LOOP_NEW, name, MemorySegment.NULL);
+ }
+
+ static void threadLoopDestroy(MemorySegment loop) {
+ invokeVoid(Handles.PW_THREAD_LOOP_DESTROY, loop);
+ }
+
+ static MemorySegment threadLoopGetLoop(MemorySegment loop) {
+ return (MemorySegment) invoke(Handles.PW_THREAD_LOOP_GET_LOOP, loop);
+ }
+
+ static int threadLoopStart(MemorySegment loop) {
+ return (int) invoke(Handles.PW_THREAD_LOOP_START, loop);
+ }
+
+ static void threadLoopStop(MemorySegment loop) {
+ invokeVoid(Handles.PW_THREAD_LOOP_STOP, loop);
+ }
+
+ static void threadLoopLock(MemorySegment loop) {
+ invokeVoid(Handles.PW_THREAD_LOOP_LOCK, loop);
+ }
+
+ static void threadLoopUnlock(MemorySegment loop) {
+ invokeVoid(Handles.PW_THREAD_LOOP_UNLOCK, loop);
+ }
+
+ static void threadLoopSignal(MemorySegment loop, boolean waitForAccept) {
+ invokeVoid(Handles.PW_THREAD_LOOP_SIGNAL, loop, waitForAccept);
+ }
+
+ /** Waits for a {@code signal}, returning {@code -ETIMEDOUT} when {@code seconds} elapse. */
+ static int threadLoopTimedWait(MemorySegment loop, int seconds) {
+ return (int) invoke(Handles.PW_THREAD_LOOP_TIMED_WAIT, loop, seconds);
+ }
+
+ // ---- context / core ----
+
+ static MemorySegment contextNew(MemorySegment loop, MemorySegment properties) {
+ return (MemorySegment) invoke(Handles.PW_CONTEXT_NEW, loop, properties, 0L);
+ }
+
+ static MemorySegment contextConnect(MemorySegment context, MemorySegment properties) {
+ return (MemorySegment) invoke(Handles.PW_CONTEXT_CONNECT, context, properties, 0L);
+ }
+
+ static void contextDestroy(MemorySegment context) {
+ invokeVoid(Handles.PW_CONTEXT_DESTROY, context);
+ }
+
+ static int coreDisconnect(MemorySegment core) {
+ return (int) invoke(Handles.PW_CORE_DISCONNECT, core);
+ }
+
+ static void proxyDestroy(MemorySegment proxy) {
+ invokeVoid(Handles.PW_PROXY_DESTROY, proxy);
+ }
+
+ static MemorySegment propertiesNewDict(MemorySegment dict) {
+ return (MemorySegment) invoke(Handles.PW_PROPERTIES_NEW_DICT, dict);
+ }
+
+ // ---- stream ----
+
+ static MemorySegment streamNewSimple(MemorySegment loop, MemorySegment name,
+ MemorySegment properties, MemorySegment events,
+ MemorySegment data) {
+ return (MemorySegment) invoke(Handles.PW_STREAM_NEW_SIMPLE, loop, name, properties, events, data);
+ }
+
+ static int streamConnect(MemorySegment stream, int direction, int targetId, int flags,
+ MemorySegment params, int paramCount) {
+ return (int) invoke(Handles.PW_STREAM_CONNECT, stream, direction, targetId, flags, params, paramCount);
+ }
+
+ static int streamDisconnect(MemorySegment stream) {
+ return (int) invoke(Handles.PW_STREAM_DISCONNECT, stream);
+ }
+
+ static void streamDestroy(MemorySegment stream) {
+ invokeVoid(Handles.PW_STREAM_DESTROY, stream);
+ }
+
+ static int streamSetActive(MemorySegment stream, boolean active) {
+ return (int) invoke(Handles.PW_STREAM_SET_ACTIVE, stream, active);
+ }
+
+ static int streamFlush(MemorySegment stream, boolean drain) {
+ return (int) invoke(Handles.PW_STREAM_FLUSH, stream, drain);
+ }
+
+ static MemorySegment streamDequeueBuffer(MemorySegment stream) {
+ return (MemorySegment) invoke(Handles.PW_STREAM_DEQUEUE_BUFFER, stream);
+ }
+
+ static int streamQueueBuffer(MemorySegment stream, MemorySegment buffer) {
+ return (int) invoke(Handles.PW_STREAM_QUEUE_BUFFER, stream, buffer);
+ }
+
+ // ---- spa_interface method dispatch ----
+
+ /** The {@code cb.data} pointer an interface's methods expect as their first argument. */
+ static MemorySegment interfaceSelf(MemorySegment iface) {
+ return iface.reinterpret(SPA_INTERFACE_SIZE).get(PTR, SPA_INTERFACE_CB_DATA);
+ }
+
+ /**
+ * Binds slot {@code slot} of an interface's method table as a downcall. The table is
+ * a {@code uint32_t version} followed by one function pointer per method, so the slot
+ * is the pointer index (1 for the first method).
+ */
+ static MethodHandle interfaceMethod(MemorySegment iface, int slot, FunctionDescriptor descriptor) {
+ MemorySegment methods = iface.reinterpret(SPA_INTERFACE_SIZE).get(PTR, SPA_INTERFACE_CB_FUNCS);
+ MemorySegment function = methods.reinterpret(8L * (slot + 1)).get(PTR, 8L * slot);
+ if (function.address() == 0) {
+ throw new UnsatisfiedLinkError("libpipewire: interface method slot " + slot + " is null");
+ }
+ return LINKER.downcallHandle(function, descriptor);
+ }
+
+ // ---- helpers ----
+
+ /** Reads a NUL-terminated C string from an unbounded pointer; {@code null} for NULL. */
+ static String cString(MemorySegment pointer) {
+ if (pointer == null || pointer.address() == 0) return null;
+ return pointer.reinterpret(Long.MAX_VALUE).getString(0);
+ }
+
+ /** Gives a pointer returned by C a known extent so its fields can be read. */
+ static MemorySegment at(MemorySegment pointer, long size) {
+ return pointer.reinterpret(size);
+ }
+
+ static Object invoke(MethodHandle handle, Object... arguments) {
+ try {
+ return handle.invokeWithArguments(arguments);
+ } catch (Throwable t) {
+ throw wrap(t);
+ }
+ }
+
+ static void invokeVoid(MethodHandle handle, Object... arguments) {
+ invoke(handle, arguments);
+ }
+
+ private static RuntimeException wrap(Throwable t) {
+ if (t instanceof RuntimeException re) return re;
+ if (t instanceof Error e) throw e;
+ return new IllegalStateException("libpipewire call failed", t);
+ }
+}
diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/pipewire/PipeWirePlayback.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/pipewire/PipeWirePlayback.java
new file mode 100644
index 0000000..d370a6a
--- /dev/null
+++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/pipewire/PipeWirePlayback.java
@@ -0,0 +1,47 @@
+package com.ts3client.audio.desktop.pipewire;
+
+import com.ts3client.audio.desktop.AudioPlayback;
+
+import java.lang.foreign.MemorySegment;
+
+import static com.ts3client.audio.desktop.pipewire.PipeWireLibrary.INT;
+import static com.ts3client.audio.desktop.pipewire.PipeWireLibrary.LONG;
+
+/** A PipeWire playback stream ({@code PW_DIRECTION_OUTPUT}). */
+final class PipeWirePlayback extends PipeWireStream implements AudioPlayback {
+
+ PipeWirePlayback(PipeWireSession session, String name, String targetNode, int rate, int channels) {
+ super(session, name, targetNode, rate, channels, false);
+ }
+
+ /**
+ * Fills the buffer with as much queued audio as there is, padding with silence on an
+ * underrun so the graph always gets a full quantum.
+ */
+ @Override
+ void process(MemorySegment header, MemorySegment audio, MemorySegment chunk, int maxSize) {
+ int stride = stride();
+ long requested = header.get(LONG, PipeWireLibrary.PW_BUFFER_REQUESTED);
+ int bytes = maxSize;
+ if (requested > 0) bytes = (int) Math.min(bytes, requested * stride);
+ bytes -= bytes % stride;
+
+ int filled = ring.take(audio, 0, bytes);
+ if (filled < bytes) audio.asSlice(filled, bytes - filled).fill((byte) 0);
+
+ chunk.set(INT, PipeWireLibrary.SPA_CHUNK_OFFSET, 0);
+ chunk.set(INT, PipeWireLibrary.SPA_CHUNK_STRIDE, stride);
+ chunk.set(INT, PipeWireLibrary.SPA_CHUNK_SIZE, bytes);
+ header.set(LONG, PipeWireLibrary.PW_BUFFER_SIZE, bytes / stride);
+ }
+
+ @Override
+ public void write(byte[] buffer, int offset, int length) {
+ ring.write(buffer, offset, length);
+ }
+
+ @Override
+ public void drain() {
+ ring.drain();
+ }
+}
diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/pipewire/PipeWireSession.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/pipewire/PipeWireSession.java
new file mode 100644
index 0000000..057afaa
--- /dev/null
+++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/pipewire/PipeWireSession.java
@@ -0,0 +1,287 @@
+package com.ts3client.audio.desktop.pipewire;
+
+import java.lang.foreign.Arena;
+import java.lang.foreign.FunctionDescriptor;
+import java.lang.foreign.MemorySegment;
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static com.ts3client.audio.desktop.pipewire.PipeWireLibrary.INT;
+import static com.ts3client.audio.desktop.pipewire.PipeWireLibrary.PTR;
+
+/**
+ * The process-wide connection to the PipeWire daemon.
+ *
+ * Owns the thread loop that every stream runs on, and keeps a live view of the
+ * graph's audio nodes by listening to the registry — so the device list is always
+ * current without polling. The session is created on first use and then kept: the
+ * loop is a native thread that does not hold the JVM open, and re-connecting per
+ * device query would cost a round trip each time.
+ *
+ * Every libpipewire call from outside the loop thread must hold the loop lock; the
+ * {@code lock}/{@code unlock} pairs below are that lock, not a Java monitor.
+ */
+final class PipeWireSession {
+
+ /** An audio sink (playback device) or source (capture device) in the graph. */
+ record Node(String name, String description, boolean sink) {
+ }
+
+ private static final int ROUNDTRIP_TIMEOUT_SECONDS = 2;
+
+ private static volatile PipeWireSession instance;
+ private static volatile boolean failed;
+
+ private final Arena arena = Arena.ofShared();
+ private final MemorySegment threadLoop;
+ private final MemorySegment loop;
+ private final MemorySegment context;
+ private final MemorySegment core;
+ private final MemorySegment registry;
+
+ private final MethodHandle coreSync;
+
+ /** Nodes by global id, in the order the registry announced them. Guarded by itself. */
+ private final Map PipeWire is pull-based: it calls {@code process} on the loop thread whenever a
+ * buffer can be filled (playback) or has arrived (capture). The voice pipeline, on the
+ * other hand, wants to block on a line. {@link PcmRing} bridges the two — see there for
+ * what happens when the two sides drift apart.
+ *
+ * {@code PW_STREAM_FLAG_RT_PROCESS} is deliberately not used: it would run the
+ * callback on a real-time thread, where a garbage collection pause would be an xrun.
+ * Without it PipeWire schedules the callback on the loop thread instead, which is a
+ * normal thread and can safely run Java code.
+ */
+abstract class PipeWireStream {
+
+ /** Ring capacity, as a multiple of the 20 ms frame the pipeline works in. */
+ private static final int RING_FRAMES = 20;
+
+ private static final int QUANTUM = 960; // 20 ms at 48 kHz
+
+ private final PipeWireSession session;
+ private final Arena arena = Arena.ofShared();
+ private final int channels;
+ private final int rate;
+ private final int stride;
+ protected final PcmRing ring;
+
+ private volatile MemorySegment stream;
+ private volatile boolean started;
+ private volatile boolean closed;
+
+ PipeWireStream(PipeWireSession session, String name, String targetNode,
+ int rate, int channels, boolean capture) {
+ this.session = session;
+ this.rate = rate;
+ this.channels = channels;
+ this.stride = channels * 2; // S16, interleaved
+ this.ring = new PcmRing(QUANTUM * stride * RING_FRAMES);
+
+ session.lock();
+ try {
+ stream = PipeWireLibrary.streamNewSimple(session.loop(), arena.allocateFrom(name),
+ properties(name, targetNode, capture), events(), MemorySegment.NULL);
+ if (stream.address() == 0) {
+ throw new IllegalStateException("pw_stream_new_simple failed");
+ }
+ MemorySegment params = arena.allocate(PTR, 1);
+ params.set(PTR, 0, SpaPod.audioFormat(arena, rate, channels));
+
+ int flags = PipeWireLibrary.PW_STREAM_FLAG_AUTOCONNECT
+ | PipeWireLibrary.PW_STREAM_FLAG_MAP_BUFFERS
+ | PipeWireLibrary.PW_STREAM_FLAG_INACTIVE;
+ int result = PipeWireLibrary.streamConnect(stream,
+ capture ? PipeWireLibrary.PW_DIRECTION_INPUT : PipeWireLibrary.PW_DIRECTION_OUTPUT,
+ PipeWireLibrary.PW_ID_ANY, flags, params, 1);
+ if (result < 0) {
+ throw new IllegalStateException("pw_stream_connect failed: " + result);
+ }
+ } catch (Throwable t) {
+ session.unlock();
+ close();
+ throw t;
+ }
+ session.unlock();
+ }
+
+ /**
+ * Properties the server uses to place and label the stream: the target device, the
+ * name shown in volume mixers, and the quantum we would like to be called with.
+ */
+ private MemorySegment properties(String name, String targetNode, boolean capture) {
+ Map A POD is a length-prefixed tree: every value is an 8-byte header
+ * ({@code uint32_t size; uint32_t type;}) followed by {@code size} bytes of body, padded
+ * out to a multiple of 8. An object adds {@code uint32_t type; uint32_t id;} in front of
+ * its properties, and each property is {@code uint32_t key; uint32_t flags;} followed by
+ * one value POD.
+ *
+ * libpipewire only offers {@code spa_pod_builder} as inline C, so the bytes are
+ * assembled here instead. {@code SpaPodTest} pins the output against the bytes the real
+ * {@code spa_format_audio_raw_build()} produces.
+ */
+final class SpaPod {
+
+ // ---- enum spa_type ----
+ private static final int SPA_TYPE_ID = 3;
+ private static final int SPA_TYPE_INT = 4;
+ private static final int SPA_TYPE_ARRAY = 13;
+ private static final int SPA_TYPE_OBJECT = 15;
+ private static final int SPA_TYPE_OBJECT_FORMAT = 0x0004_0003;
+
+ // ---- enum spa_param_type ----
+ static final int SPA_PARAM_ENUM_FORMAT = 3;
+
+ // ---- enum spa_format (object property keys) ----
+ private static final int SPA_FORMAT_MEDIA_TYPE = 1;
+ private static final int SPA_FORMAT_MEDIA_SUBTYPE = 2;
+ private static final int SPA_FORMAT_AUDIO_FORMAT = 0x0001_0001;
+ private static final int SPA_FORMAT_AUDIO_RATE = 0x0001_0003;
+ private static final int SPA_FORMAT_AUDIO_CHANNELS = 0x0001_0004;
+ private static final int SPA_FORMAT_AUDIO_POSITION = 0x0001_0005;
+
+ private static final int SPA_MEDIA_TYPE_AUDIO = 1;
+ private static final int SPA_MEDIA_SUBTYPE_RAW = 1;
+
+ /** {@code SPA_AUDIO_FORMAT_S16_LE} — interleaved signed 16-bit, what our pipeline uses. */
+ static final int SPA_AUDIO_FORMAT_S16_LE = 0x103;
+
+ // ---- enum spa_audio_channel ----
+ private static final int SPA_AUDIO_CHANNEL_UNKNOWN = 0;
+ private static final int SPA_AUDIO_CHANNEL_MONO = 2;
+ private static final int SPA_AUDIO_CHANNEL_FL = 3;
+ private static final int SPA_AUDIO_CHANNEL_FR = 4;
+
+ private final ByteBuffer buffer;
+
+ private SpaPod(int capacity) {
+ this.buffer = ByteBuffer.allocate(capacity).order(ByteOrder.LITTLE_ENDIAN);
+ }
+
+ /**
+ * Builds the single {@code EnumFormat} parameter a stream offers when connecting:
+ * interleaved S16 audio at one fixed rate and channel count. Offering exactly one
+ * format lets the server put its own converter in front of us, so the negotiated
+ * format is always the one asked for here.
+ */
+ static MemorySegment audioFormat(SegmentAllocator allocator, int rate, int channels) {
+ SpaPod pod = new SpaPod(256);
+ pod.beginObject(SPA_TYPE_OBJECT_FORMAT, SPA_PARAM_ENUM_FORMAT);
+ pod.id(SPA_FORMAT_MEDIA_TYPE, SPA_MEDIA_TYPE_AUDIO);
+ pod.id(SPA_FORMAT_MEDIA_SUBTYPE, SPA_MEDIA_SUBTYPE_RAW);
+ pod.id(SPA_FORMAT_AUDIO_FORMAT, SPA_AUDIO_FORMAT_S16_LE);
+ pod.integer(SPA_FORMAT_AUDIO_RATE, rate);
+ pod.integer(SPA_FORMAT_AUDIO_CHANNELS, channels);
+ pod.idArray(SPA_FORMAT_AUDIO_POSITION, positions(channels));
+ pod.endObject();
+ return pod.toSegment(allocator);
+ }
+
+ /** Channel map for the layouts we use; anything wider is left for the server to assign. */
+ private static int[] positions(int channels) {
+ return switch (channels) {
+ case 1 -> new int[]{SPA_AUDIO_CHANNEL_MONO};
+ case 2 -> new int[]{SPA_AUDIO_CHANNEL_FL, SPA_AUDIO_CHANNEL_FR};
+ default -> {
+ int[] unknown = new int[channels];
+ java.util.Arrays.fill(unknown, SPA_AUDIO_CHANNEL_UNKNOWN);
+ yield unknown;
+ }
+ };
+ }
+
+ // ---- writing ----
+
+ /** Offset of the size field of the object being built, patched by {@link #endObject}. */
+ private int objectSizeOffset = -1;
+
+ private void beginObject(int type, int id) {
+ objectSizeOffset = buffer.position();
+ buffer.putInt(0); // size, patched in endObject
+ buffer.putInt(SPA_TYPE_OBJECT);
+ buffer.putInt(type);
+ buffer.putInt(id);
+ }
+
+ private void endObject() {
+ int bodyStart = objectSizeOffset + 8;
+ buffer.putInt(objectSizeOffset, buffer.position() - bodyStart);
+ }
+
+ private void id(int key, int value) {
+ property(key, SPA_TYPE_ID, 4);
+ buffer.putInt(value);
+ pad();
+ }
+
+ private void integer(int key, int value) {
+ property(key, SPA_TYPE_INT, 4);
+ buffer.putInt(value);
+ pad();
+ }
+
+ /** An array POD: a child header describing one element, then the elements. */
+ private void idArray(int key, int[] values) {
+ property(key, SPA_TYPE_ARRAY, 8 + 4 * values.length);
+ buffer.putInt(4);
+ buffer.putInt(SPA_TYPE_ID);
+ for (int value : values) {
+ buffer.putInt(value);
+ }
+ pad();
+ }
+
+ /** Writes {@code key/flags} plus the value header; the caller writes the body. */
+ private void property(int key, int type, int size) {
+ buffer.putInt(key);
+ buffer.putInt(0); // flags
+ buffer.putInt(size);
+ buffer.putInt(type);
+ }
+
+ /** Bodies are padded to 8 bytes; the padding is not counted in the declared size. */
+ private void pad() {
+ while ((buffer.position() & 7) != 0) {
+ buffer.put((byte) 0);
+ }
+ }
+
+ private MemorySegment toSegment(SegmentAllocator allocator) {
+ MemorySegment segment = allocator.allocate(buffer.position(), 8);
+ MemorySegment.copy(MemorySegment.ofBuffer(buffer.flip()), 0, segment, 0, segment.byteSize());
+ return segment;
+ }
+
+ /** The bytes this builder produced, for tests. */
+ static byte[] audioFormatBytes(int rate, int channels) {
+ try (java.lang.foreign.Arena arena = java.lang.foreign.Arena.ofConfined()) {
+ MemorySegment pod = audioFormat(arena, rate, channels);
+ return pod.toArray(ValueLayout.JAVA_BYTE);
+ }
+ }
+}
diff --git a/ts3-client/desktop/src/test/java/com/ts3client/audio/desktop/pipewire/SpaPodTest.java b/ts3-client/desktop/src/test/java/com/ts3client/audio/desktop/pipewire/SpaPodTest.java
new file mode 100644
index 0000000..82f484b
--- /dev/null
+++ b/ts3-client/desktop/src/test/java/com/ts3client/audio/desktop/pipewire/SpaPodTest.java
@@ -0,0 +1,44 @@
+package com.ts3client.audio.desktop.pipewire;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.HexFormat;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Pins {@link SpaPod} to the bytes libpipewire itself produces.
+ *
+ * The expected values are the output of {@code spa_format_audio_raw_build()} (pipewire
+ * 1.4, {@code SPA_AUDIO_FORMAT_S16_LE} at 48 kHz), captured from a C program linked
+ * against the real library. Since the POD layout is what the server parses, a mismatch
+ * here is a stream that will fail to negotiate.
+ */
+class SpaPodTest {
+
+ private static final String MONO_48K =
+ "a00000000f000000030004000300000001000000000000000400000003000000"
+ + "0100000000000000020000000000000004000000030000000100000000000000"
+ + "0100010000000000040000000300000003010000000000000300010000000000"
+ + "040000000400000080bb00000000000004000100000000000400000004000000"
+ + "010000000000000005000100000000000c0000000d0000000400000003000000"
+ + "0200000000000000";
+
+ private static final String STEREO_48K =
+ "a00000000f000000030004000300000001000000000000000400000003000000"
+ + "0100000000000000020000000000000004000000030000000100000000000000"
+ + "0100010000000000040000000300000003010000000000000300010000000000"
+ + "040000000400000080bb00000000000004000100000000000400000004000000"
+ + "02000000000000000500010000000000100000000d0000000400000003000000"
+ + "0300000004000000";
+
+ @Test
+ void buildsTheFormatLibpipewireExpects() {
+ assertEquals(MONO_48K, hex(SpaPod.audioFormatBytes(48_000, 1)));
+ assertEquals(STEREO_48K, hex(SpaPod.audioFormatBytes(48_000, 2)));
+ }
+
+ private static String hex(byte[] bytes) {
+ return HexFormat.of().formatHex(bytes);
+ }
+}
diff --git a/ts3-client/pom.xml b/ts3-client/pom.xml
index 5895cd1..76f8a85 100644
--- a/ts3-client/pom.xml
+++ b/ts3-client/pom.xml
@@ -22,8 +22,21 @@
Interface methods
+ * The registry API ({@code pw_core_get_registry}, {@code pw_registry_add_listener},
+ * {@code pw_core_sync}, ...) is declared {@code static inline} in the headers. PipeWire
+ * only began exporting compiled copies of those helpers in 1.1; to work on older
+ * versions too they are instead called the way the inline code does, through the
+ * object's {@code spa_interface} method table — see {@link #interfaceMethod}.
+ *
+ *
"
+ + "ALSA: entries talk to the sound card directly, taking it exclusively.";
+ 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("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());
@@ -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