diff --git a/ts3-client/desktop/pom.xml b/ts3-client/desktop/pom.xml index 7c15870..a4fd4a1 100644 --- a/ts3-client/desktop/pom.xml +++ b/ts3-client/desktop/pom.xml @@ -12,7 +12,7 @@ ts3-client-desktop TS3J Client Desktop Audio - Desktop audio backend: Java Sound capture/playback and native Opus via the FFM API + Desktop audio backend: PipeWire/Java Sound capture/playback and native Opus via the FFM API @@ -23,6 +23,12 @@ com.github.manevolent ts3j + + org.junit.jupiter + junit-jupiter + 5.13.4 + test + diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/AudioCapture.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/AudioCapture.java new file mode 100644 index 0000000..30441d4 --- /dev/null +++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/AudioCapture.java @@ -0,0 +1,26 @@ +package com.ts3client.audio.desktop; + +/** + * An open microphone line, delivering 16-bit little-endian PCM at + * {@link AudioDevices#SAMPLE_RATE}. + * + *

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 inputDeviceNames() { - return deviceNames(new DataLine.Info(TargetDataLine.class, CAPTURE_FORMAT)); + /** 48 kHz signed 16-bit little-endian PCM with the given channel count. */ + public static AudioFormat format(int channels) { + return new AudioFormat(SAMPLE_RATE, 16, channels, true, false); } - /** Names of mixers that can provide speaker (playback) lines. */ - public static List outputDeviceNames() { - return deviceNames(new DataLine.Info(SourceDataLine.class, PLAYBACK_FORMAT)); + /** Devices that can provide microphone (capture) lines, system default first. */ + public static List inputDevices() { + return devices(TargetDataLine.class, false); } - private static List deviceNames(Line.Info lineInfo) { - List names = new ArrayList<>(); + /** Devices that can provide speaker (playback) lines, system default first. */ + public static List outputDevices() { + return devices(SourceDataLine.class, true); + } + + private static List devices(Class lineClass, boolean sinks) { + List devices = new ArrayList<>(); + devices.add(Device.DEFAULT); + + for (PipeWire.Node node : PipeWire.nodes()) { + if (node.sink() == sinks) { + devices.add(new Device(PIPEWIRE_PREFIX + node.name(), node.description())); + } + } + + Line.Info anyFormat = new Line.Info(lineClass); for (Mixer.Info mi : AudioSystem.getMixerInfo()) { + String name = mi.getName(); + if (name == null || name.isEmpty()) continue; + // The ALSA "default" PCM is what the system-default entry already opens. + if (isDefaultMixer(name)) continue; + if (!AudioSystem.getMixer(mi).isLineSupported(anyFormat)) continue; + if (devices.stream().anyMatch(d -> d.id().equals(name))) continue; + devices.add(new Device(name, "ALSA: " + name)); + } + return devices; + } + + /** + * Java Sound names its ALSA {@code default} mixer after the PCM it opened, which under + * pipewire-alsa is our own stream node (e.g. {@code alsa_playback.java [default]}), so + * only the bracketed device part is stable. + */ + private static boolean isDefaultMixer(String mixerName) { + return mixerName.endsWith("[default]"); + } + + /** + * Opens a capture line, preferring {@code preferredChannels} and falling back to the + * other channel count if the device won't take it. Inspect {@link AudioCapture#channels()} + * for what was actually opened. + */ + public static AudioCapture openCapture(String deviceId, int preferredChannels) + throws LineUnavailableException { + if (usePipeWire(deviceId)) { + try { + return PipeWire.openCapture("TS3J Microphone", pipeWireNode(deviceId), + SAMPLE_RATE, clampChannels(preferredChannels)); + } catch (Exception e) { + if (isPipeWireDevice(deviceId)) throw unavailable(true, deviceId, e); + // The default device: Java Sound may still reach it through ALSA. + } + } + return new JavaSoundCapture(open(TargetDataLine.class, deviceId, preferredChannels)); + } + + /** Opens a playback line; see {@link #openCapture} for the channel negotiation. */ + public static AudioPlayback openPlayback(String deviceId, int preferredChannels) + throws LineUnavailableException { + if (usePipeWire(deviceId)) { + try { + return PipeWire.openPlayback("TS3J Playback", pipeWireNode(deviceId), + SAMPLE_RATE, clampChannels(preferredChannels)); + } catch (Exception e) { + if (isPipeWireDevice(deviceId)) throw unavailable(false, deviceId, e); + } + } + return new JavaSoundPlayback(open(SourceDataLine.class, deviceId, preferredChannels)); + } + + /** + * PipeWire handles its own devices and, when a session is running, the system default + * too — a Java Sound "default" would only reach the same graph through the ALSA + * compatibility plugin, with no way to pick the device. + */ + private static boolean usePipeWire(String deviceId) { + if (!isPipeWireDevice(deviceId) && !isDefault(deviceId)) return false; + return PipeWire.isAvailable(); + } + + private static boolean isDefault(String deviceId) { + return deviceId == null || deviceId.isEmpty(); + } + + private static boolean isPipeWireDevice(String deviceId) { + return deviceId != null && deviceId.startsWith(PIPEWIRE_PREFIX); + } + + /** The PipeWire node name behind a device id; empty for "let the server choose". */ + private static String pipeWireNode(String deviceId) { + return isPipeWireDevice(deviceId) ? deviceId.substring(PIPEWIRE_PREFIX.length()) : ""; + } + + /** PipeWire converts freely, so any channel count works; keep it in the range we handle. */ + private static int clampChannels(int channels) { + return Math.max(1, Math.min(MAX_CHANNELS, channels)); + } + + private static T open(Class lineClass, String deviceId, int preferredChannels) + throws LineUnavailableException { + Mixer mixer = findMixer(deviceId, lineClass); // null -> platform default + Exception failure = null; + for (int channels : channelOrder(preferredChannels)) { + AudioFormat fmt = format(channels); + DataLine.Info info = new DataLine.Info(lineClass, fmt); + try { + T line = lineClass.cast( + (mixer != null) ? mixer.getLine(info) : AudioSystem.getLine(info)); + openLine(line, fmt, FRAME_SIZE * 2 * fmt.getChannels() * BUFFER_FRAMES); + return line; + } catch (Exception e) { + failure = e; + } + } + throw unavailable(lineClass == TargetDataLine.class, deviceId, failure); + } + + private static LineUnavailableException unavailable(boolean capture, String deviceId, + Exception failure) { + LineUnavailableException e = new LineUnavailableException( + "No usable " + (capture ? "capture" : "playback") + " line for " + + (isDefault(deviceId) ? "the system default" : deviceId)); + if (failure != null) e.initCause(failure); + return e; + } + + /** {@code open(format, bufferSize)} lives on the two line interfaces, not on {@code DataLine}. */ + private static void openLine(DataLine line, AudioFormat fmt, int bufferSize) + throws LineUnavailableException { + if (line instanceof TargetDataLine target) target.open(fmt, bufferSize); + else ((SourceDataLine) line).open(fmt, bufferSize); + } + + private static int[] channelOrder(int preferred) { + return preferred >= 2 ? new int[]{2, 1} : new int[]{1, 2}; + } + + private static Mixer findMixer(String deviceId, Class lineClass) { + if (isDefault(deviceId) || isPipeWireDevice(deviceId)) return null; + Line.Info anyFormat = new Line.Info(lineClass); + for (Mixer.Info mi : AudioSystem.getMixerInfo()) { + if (!deviceId.equals(mi.getName())) continue; Mixer mixer = AudioSystem.getMixer(mi); - if (mixer.isLineSupported(lineInfo)) { - String name = mi.getName(); - if (name != null && !names.contains(name)) { - names.add(name); - } - } + if (mixer.isLineSupported(anyFormat)) return mixer; } - return names; - } - - public static TargetDataLine openCapture(String deviceName) throws Exception { - DataLine.Info info = new DataLine.Info(TargetDataLine.class, CAPTURE_FORMAT); - Mixer.Info mixerInfo = findMixer(deviceName, info); - TargetDataLine line = (mixerInfo != null) - ? (TargetDataLine) AudioSystem.getMixer(mixerInfo).getLine(info) - : (TargetDataLine) AudioSystem.getLine(info); - line.open(CAPTURE_FORMAT, FRAME_SIZE * 2 * 8); // ~8 frame buffer - return line; - } - - public static SourceDataLine openPlayback(String deviceName) throws Exception { - DataLine.Info info = new DataLine.Info(SourceDataLine.class, PLAYBACK_FORMAT); - Mixer.Info mixerInfo = findMixer(deviceName, info); - SourceDataLine line = (mixerInfo != null) - ? (SourceDataLine) AudioSystem.getMixer(mixerInfo).getLine(info) - : (SourceDataLine) AudioSystem.getLine(info); - line.open(PLAYBACK_FORMAT, FRAME_SIZE * 2 * 8); - return line; - } - - private static Mixer.Info findMixer(String deviceName, Line.Info lineInfo) { - if (deviceName == null || deviceName.isEmpty()) return null; - for (Mixer.Info mi : AudioSystem.getMixerInfo()) { - if (deviceName.equals(mi.getName()) && AudioSystem.getMixer(mi).isLineSupported(lineInfo)) { - return mi; - } - } - return null; // fall back to system default + return null; // device went away: fall back to the system default } } diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/AudioPlayback.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/AudioPlayback.java new file mode 100644 index 0000000..211e25f --- /dev/null +++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/AudioPlayback.java @@ -0,0 +1,26 @@ +package com.ts3client.audio.desktop; + +/** + * An open speaker line, accepting 16-bit little-endian PCM at + * {@link AudioDevices#SAMPLE_RATE}. + * + *

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 ll = levelListener; - if (ll != null) ll.accept(db); - - boolean open = decideGate(db, pcm); - setTransmitting(open); - - if (open && !muted.get()) { - try { - byte[] packet; - synchronized (encoderLock) { - packet = encoder != null ? encoder.encode(pcm) : null; - } - if (packet != null && packet.length > 0) { - queue.offer(packet); - // Guard against unbounded growth if the network stalls. - while (queue.size() > 10) queue.poll(); - } - } catch (Exception ignored) { + if (channels > 1) { + for (int i = 0; i < frameSamples; i++) { + float sum = 0; + for (int c = 0; c < channels; c++) sum += pcm[i * channels + c]; + mono[i] = sum / channels; } } + + byte[] packet = null; + synchronized (encoderLock) { + OpusParameters p = params; + if (p != appliedParams && encoder != null) applyParams(p); + boolean stereo = encoderChannels == 2; + + // Denoise / typing attenuation are voice-chain stages: they feed the + // level meter, VAD and encoder alike on the mono path. A stereo (music) + // stream bypasses them and is transmitted as captured. + if (!stereo) enhancer.process(mono, frameSamples); + + double sumSq = 0; + for (int i = 0; i < frameSamples; i++) { + sumSq += (double) mono[i] * mono[i]; + } + double rms = Math.sqrt(sumSq / frameSamples); + double db = (rms <= 1e-9) ? -100.0 : 20.0 * Math.log10(rms); + Consumer ll = levelListener; + if (ll != null) ll.accept(db); + + boolean open = decideGate(db, mono); + setTransmitting(open); + + if (open && !muted.get() && encoder != null) { + try { + packet = encoder.encode(stereo ? pcm : mono); + } catch (Exception ignored) { + } + } + } + + if (packet != null && packet.length > 0) { + queue.offer(packet); + // Guard against unbounded growth if the network stalls. + while (queue.size() > 10) queue.poll(); + } } } diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/JavaSoundVoiceOutput.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopVoiceOutput.java similarity index 55% rename from ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/JavaSoundVoiceOutput.java rename to ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopVoiceOutput.java index 7c2b515..d8b7f05 100644 --- a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/JavaSoundVoiceOutput.java +++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopVoiceOutput.java @@ -1,10 +1,10 @@ package com.ts3client.audio.desktop; +import com.github.manevolent.ts3j.enums.CodecType; import com.github.manevolent.ts3j.protocol.packet.PacketBody0Voice; import com.github.manevolent.ts3j.protocol.packet.PacketBody1VoiceWhisper; import com.ts3client.audio.VoiceOutput; -import javax.sound.sampled.SourceDataLine; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -13,26 +13,35 @@ import java.util.concurrent.Executors; import java.util.function.BiConsumer; /** - * Java Sound {@link VoiceOutput}: decodes and plays incoming voice per speaker. + * Desktop {@link VoiceOutput}: decodes and plays incoming voice per speaker. * Each client gets its own Opus decoder, playback line and worker thread, so - * simultaneous speakers are mixed by the OS and one slow decode never blocks - * another (or the network thread). + * simultaneous speakers are mixed by the audio server and one slow decode never + * blocks another (or the network thread). Lines come from {@link AudioDevices}, + * so on a PipeWire desktop every speaker is a separate stream in the mixer. */ -public final class JavaSoundVoiceOutput implements VoiceOutput { +public final class DesktopVoiceOutput implements VoiceOutput { + + /** Longest Opus frame (120 ms @ 48 kHz) a packet may decode to, per channel. */ + private static final int MAX_FRAME = 5760; /** One speaker's decode + playback pipeline. */ private final class ClientStream { final int clientId; - final OpusDecoder decoder; - final SourceDataLine line; + final AudioPlayback line; + final int lineChannels; final ExecutorService worker; + /** Decoder scratch and byte buffer, touched only by {@link #worker}. */ + final float[] pcm = new float[MAX_FRAME * AudioDevices.MAX_CHANNELS]; + byte[] out = new byte[0]; + OpusDecoder decoder; + int decoderChannels; volatile boolean talking; volatile long lastPacketNanos; ClientStream(int clientId) throws Exception { this.clientId = clientId; - this.decoder = new OpusDecoder(AudioDevices.SAMPLE_RATE, AudioDevices.FRAME_SIZE, 1); - this.line = AudioDevices.openPlayback(outputDevice); + this.line = AudioDevices.openPlayback(outputDevice, AudioDevices.MAX_CHANNELS); + this.lineChannels = line.channels(); this.line.start(); this.worker = Executors.newSingleThreadExecutor(r -> { Thread t = new Thread(r, "ts3j-play-" + clientId); @@ -41,14 +50,24 @@ public final class JavaSoundVoiceOutput implements VoiceOutput { }); } + /** + * Returns a decoder matching the stream's channel count, rebuilding it when a + * speaker switches between the mono {@code OPUS_VOICE} and stereo + * {@code OPUS_MUSIC} codecs. + */ + OpusDecoder decoderFor(int channels) { + if (decoder == null || decoderChannels != channels) { + if (decoder != null) decoder.close(); + decoder = new OpusDecoder(AudioDevices.SAMPLE_RATE, MAX_FRAME, channels); + decoderChannels = channels; + } + return decoder; + } + void close() { worker.shutdownNow(); - try { - line.stop(); - line.close(); - } catch (Exception ignored) { - } - decoder.close(); + line.close(); + if (decoder != null) decoder.close(); } } @@ -62,7 +81,7 @@ public final class JavaSoundVoiceOutput implements VoiceOutput { /** Notified (clientId, talking) on the EDT-agnostic worker thread when a speaker starts/stops. */ private volatile BiConsumer talkListener; - public JavaSoundVoiceOutput(String outputDevice) { + public DesktopVoiceOutput(String outputDevice) { this.outputDevice = outputDevice; } @@ -103,15 +122,20 @@ public final class JavaSoundVoiceOutput implements VoiceOutput { /** Entry point wired into {@code client.setVoiceHandler(...)}. */ public void handleVoice(PacketBody0Voice voice) { - route(voice.getClientId(), voice.getCodecData()); + route(voice.getClientId(), voice.getCodecType(), voice.getCodecData()); } /** Entry point wired into {@code client.setWhisperHandler(...)}. */ public void handleWhisper(PacketBody1VoiceWhisper whisper) { - route(whisper.getClientId(), whisper.getCodecData()); + route(whisper.getClientId(), whisper.getCodecType(), whisper.getCodecData()); } - private void route(int clientId, byte[] data) { + /** TeamSpeak streams {@code OPUS_MUSIC} in stereo and everything else in mono. */ + private static int channelsFor(CodecType codec) { + return codec == CodecType.OPUS_MUSIC ? 2 : 1; + } + + private void route(int clientId, CodecType codec, byte[] data) { if (deafened) return; if (mutedClients.contains(clientId)) return; @@ -139,32 +163,51 @@ public final class JavaSoundVoiceOutput implements VoiceOutput { target.line.drain(); } catch (Exception ignored) { } - target.decoder.reset(); + if (target.decoder != null) target.decoder.reset(); markTalking(target, false); }); return; } markTalking(target, true); - target.worker.submit(() -> decodeAndPlay(target, data)); + final int channels = channelsFor(codec); + target.worker.submit(() -> decodeAndPlay(target, channels, data)); } - private void decodeAndPlay(ClientStream stream, byte[] data) { + private void decodeAndPlay(ClientStream stream, int channels, byte[] data) { try { - float[] pcm = new float[AudioDevices.FRAME_SIZE]; - int samples = stream.decoder.decode(data, pcm); + float[] pcm = stream.pcm; + int frames = stream.decoderFor(channels).decode(data, pcm); double vol = masterVolume; - byte[] out = new byte[samples * 2]; - for (int i = 0; i < samples; i++) { - double v = pcm[i] * vol; - if (v > 1.0) v = 1.0; - else if (v < -1.0) v = -1.0; - short s = (short) Math.round(v * 32767.0); - out[2 * i] = (byte) (s & 0xFF); - out[2 * i + 1] = (byte) ((s >> 8) & 0xFF); + // Match the decoded stream to the line: duplicate mono across a stereo + // line, fold a stereo (music) stream down onto a mono-only line. + int lineChannels = stream.lineChannels; + int bytes = frames * lineChannels * 2; + if (stream.out.length < bytes) stream.out = new byte[bytes]; + byte[] out = stream.out; + + for (int i = 0, k = 0; i < frames; i++) { + for (int c = 0; c < lineChannels; c++, k += 2) { + double v; + if (channels == lineChannels) { + v = pcm[i * channels + c]; + } else if (channels == 1) { + v = pcm[i]; + } else { + double sum = 0; + for (int s = 0; s < channels; s++) sum += pcm[i * channels + s]; + v = sum / channels; + } + v *= vol; + if (v > 1.0) v = 1.0; + else if (v < -1.0) v = -1.0; + short s = (short) Math.round(v * 32767.0); + out[k] = (byte) (s & 0xFF); + out[k + 1] = (byte) ((s >> 8) & 0xFF); + } } - stream.line.write(out, 0, out.length); + stream.line.write(out, 0, bytes); } catch (Exception ignored) { } } diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/JavaSoundAudioBackend.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/JavaSoundAudioBackend.java deleted file mode 100644 index cc17f16..0000000 --- a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/JavaSoundAudioBackend.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.ts3client.audio.desktop; - -import com.ts3client.audio.AudioBackend; -import com.ts3client.audio.VoiceInput; -import com.ts3client.audio.VoiceOutput; -import com.ts3client.config.Settings; - -/** - * Desktop audio backend using Java Sound for capture/playback and native Opus - * (via JNA) for the codec. - */ -public final class JavaSoundAudioBackend implements AudioBackend { - - @Override - public VoiceInput createInput(Settings settings) { - return new JavaSoundVoiceInput(settings); - } - - @Override - public VoiceOutput createOutput(Settings settings) { - return new JavaSoundVoiceOutput(settings.outputDevice); - } - - @Override - public String description() { - try { - return "Opus " + Opus.getVersionString(); - } catch (Throwable t) { - return "Opus (native library unavailable)"; - } - } -} diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/JavaSoundCapture.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/JavaSoundCapture.java new file mode 100644 index 0000000..a6187ed --- /dev/null +++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/JavaSoundCapture.java @@ -0,0 +1,49 @@ +package com.ts3client.audio.desktop; + +import javax.sound.sampled.TargetDataLine; + +/** {@link AudioCapture} backed by a Java Sound {@link TargetDataLine}. */ +final class JavaSoundCapture implements AudioCapture { + + private final TargetDataLine line; + + JavaSoundCapture(TargetDataLine line) { + this.line = line; + } + + @Override + public int channels() { + return line.getFormat().getChannels(); + } + + @Override + public void start() { + line.start(); + } + + /** {@code TargetDataLine.read} may return early, so it is repeated until satisfied. */ + @Override + public int read(byte[] buffer, int offset, int length) { + int read = 0; + while (read < length) { + int n; + try { + n = line.read(buffer, offset + read, length - read); + } catch (Exception e) { + break; + } + if (n <= 0) break; + read += n; + } + return read; + } + + @Override + public void close() { + try { + line.stop(); + line.close(); + } catch (Exception ignored) { + } + } +} diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/JavaSoundPlayback.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/JavaSoundPlayback.java new file mode 100644 index 0000000..a81ae2c --- /dev/null +++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/JavaSoundPlayback.java @@ -0,0 +1,49 @@ +package com.ts3client.audio.desktop; + +import javax.sound.sampled.SourceDataLine; + +/** {@link AudioPlayback} backed by a Java Sound {@link SourceDataLine}. */ +final class JavaSoundPlayback implements AudioPlayback { + + private final SourceDataLine line; + + JavaSoundPlayback(SourceDataLine line) { + this.line = line; + } + + @Override + public int channels() { + return line.getFormat().getChannels(); + } + + @Override + public void start() { + line.start(); + } + + @Override + public void write(byte[] buffer, int offset, int length) { + try { + line.write(buffer, offset, length); + } catch (Exception ignored) { + // The line went away (device unplugged); the caller sees silence. + } + } + + @Override + public void drain() { + try { + line.drain(); + } catch (Exception ignored) { + } + } + + @Override + public void close() { + try { + line.stop(); + line.close(); + } catch (Exception ignored) { + } + } +} diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/OpusDecoder.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/OpusDecoder.java index 48736b9..a87b16e 100644 --- a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/OpusDecoder.java +++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/OpusDecoder.java @@ -7,9 +7,9 @@ import java.lang.foreign.ValueLayout; /** * Thin wrapper around a native Opus decoder. * - *

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 nodes() { + PipeWireSession session = PipeWireSession.get(); + if (session == null) return List.of(); + return session.nodes().stream() + .map(node -> new Node(node.name(), node.description(), node.sink())) + .toList(); + } + + /** + * Opens a capture stream on {@code targetNode} (empty or {@code null} for the default + * source). + * + * @throws IllegalStateException if PipeWire is unavailable or the stream is refused + */ + public static AudioCapture openCapture(String name, String targetNode, int rate, int channels) { + return new PipeWireCapture(session(), name, targetNode, rate, channels); + } + + /** + * Opens a playback stream on {@code targetNode} (empty or {@code null} for the default + * sink). + * + * @throws IllegalStateException if PipeWire is unavailable or the stream is refused + */ + public static AudioPlayback openPlayback(String name, String targetNode, int rate, int channels) { + return new PipeWirePlayback(session(), name, targetNode, rate, channels); + } + + private static PipeWireSession session() { + PipeWireSession session = PipeWireSession.get(); + if (session == null) throw new IllegalStateException("PipeWire is not available"); + return session; + } +} diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/pipewire/PipeWireCapture.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/pipewire/PipeWireCapture.java new file mode 100644 index 0000000..88a5eaf --- /dev/null +++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/pipewire/PipeWireCapture.java @@ -0,0 +1,28 @@ +package com.ts3client.audio.desktop.pipewire; + +import com.ts3client.audio.desktop.AudioCapture; + +import java.lang.foreign.MemorySegment; + +import static com.ts3client.audio.desktop.pipewire.PipeWireLibrary.INT; + +/** A PipeWire capture stream ({@code PW_DIRECTION_INPUT}). */ +final class PipeWireCapture extends PipeWireStream implements AudioCapture { + + PipeWireCapture(PipeWireSession session, String name, String targetNode, int rate, int channels) { + super(session, name, targetNode, rate, channels, true); + } + + /** Copies the valid part of the buffer — which need not start at zero — into the ring. */ + @Override + void process(MemorySegment header, MemorySegment audio, MemorySegment chunk, int maxSize) { + int offset = Math.min(chunk.get(INT, PipeWireLibrary.SPA_CHUNK_OFFSET), maxSize); + int size = Math.min(chunk.get(INT, PipeWireLibrary.SPA_CHUNK_SIZE), maxSize - offset); + if (size > 0) ring.put(audio, offset, size); + } + + @Override + public int read(byte[] buffer, int offset, int length) { + return ring.read(buffer, offset, length); + } +} diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/pipewire/PipeWireLibrary.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/pipewire/PipeWireLibrary.java new file mode 100644 index 0000000..4cad3a3 --- /dev/null +++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/pipewire/PipeWireLibrary.java @@ -0,0 +1,409 @@ +package com.ts3client.audio.desktop.pipewire; + +import java.lang.foreign.AddressLayout; +import java.lang.foreign.Arena; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.Linker; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SymbolLookup; +import java.lang.foreign.ValueLayout; +import java.lang.invoke.MethodHandle; + +/** + * Raw binding to {@code libpipewire-0.3} through the Foreign Function & Memory API. + * + *

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. + * + *

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}. + * + *

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 nodes = new LinkedHashMap<>(); + + // Written on the caller's thread, read on the loop thread; the native loop mutex + // that orders them is invisible to the Java memory model. + private volatile int pendingSync; + private volatile boolean syncDone; + + /** + * Returns the shared session, or {@code null} when PipeWire is not usable here — + * no library, no daemon, or a connection that was refused. The first failure is + * remembered so an unavailable system is not retried on every device query. + */ + static PipeWireSession get() { + PipeWireSession existing = instance; + if (existing != null || failed) return existing; + synchronized (PipeWireSession.class) { + if (instance == null && !failed) { + try { + instance = new PipeWireSession(); + } catch (Throwable t) { + failed = true; + } + } + return instance; + } + } + + private PipeWireSession() { + if (!PipeWireLibrary.isAvailable()) { + throw new UnsupportedOperationException("libpipewire is not available"); + } + PipeWireLibrary.init(); + + threadLoop = nonNull(PipeWireLibrary.threadLoopNew(arena.allocateFrom("ts3-client")), + "pw_thread_loop_new"); + loop = PipeWireLibrary.threadLoopGetLoop(threadLoop); + context = nonNull(PipeWireLibrary.contextNew(loop, MemorySegment.NULL), "pw_context_new"); + + if (PipeWireLibrary.threadLoopStart(threadLoop) < 0) { + throw new IllegalStateException("pw_thread_loop_start failed"); + } + + PipeWireLibrary.threadLoopLock(threadLoop); + try { + core = nonNull(PipeWireLibrary.contextConnect(context, MemorySegment.NULL), + "pw_context_connect"); + coreSync = PipeWireLibrary.interfaceMethod(core, PipeWireLibrary.CORE_METHOD_SYNC, + FunctionDescriptor.of(INT, PTR, INT, INT)); + addCoreListener(); + registry = nonNull(getRegistry(), "pw_core_get_registry"); + addRegistryListener(); + roundtrip(); + } catch (Throwable t) { + PipeWireLibrary.threadLoopUnlock(threadLoop); + close(); + throw t; + } + PipeWireLibrary.threadLoopUnlock(threadLoop); + } + + // ---- registry / core wiring ---- + + private MemorySegment getRegistry() { + MethodHandle getRegistry = PipeWireLibrary.interfaceMethod(core, + PipeWireLibrary.CORE_METHOD_GET_REGISTRY, + FunctionDescriptor.of(PTR, PTR, INT, PipeWireLibrary.LONG)); + return (MemorySegment) PipeWireLibrary.invoke(getRegistry, + PipeWireLibrary.interfaceSelf(core), PipeWireLibrary.PW_VERSION_REGISTRY, 0L); + } + + private void addCoreListener() { + // Arena allocations are zero-filled, so every callback we do not set stays NULL. + MemorySegment events = arena.allocate(PipeWireLibrary.CORE_EVENTS_SIZEOF, 8); + events.set(INT, 0, PipeWireLibrary.PW_VERSION_CORE_EVENTS); + events.set(PTR, PipeWireLibrary.CORE_EVENTS_DONE, + upcall("onDone", MethodType.methodType(void.class, MemorySegment.class, int.class, int.class), + FunctionDescriptor.ofVoid(PTR, INT, INT))); + + MethodHandle addListener = PipeWireLibrary.interfaceMethod(core, + PipeWireLibrary.CORE_METHOD_ADD_LISTENER, + FunctionDescriptor.of(INT, PTR, PTR, PTR, PTR)); + PipeWireLibrary.invoke(addListener, PipeWireLibrary.interfaceSelf(core), + hook(), events, MemorySegment.NULL); + } + + private void addRegistryListener() { + MemorySegment events = arena.allocate(PipeWireLibrary.REGISTRY_EVENTS_SIZEOF, 8); + events.set(INT, 0, PipeWireLibrary.PW_VERSION_REGISTRY_EVENTS); + events.set(PTR, PipeWireLibrary.REGISTRY_EVENTS_GLOBAL, + upcall("onGlobal", MethodType.methodType(void.class, MemorySegment.class, int.class, + int.class, MemorySegment.class, int.class, MemorySegment.class), + FunctionDescriptor.ofVoid(PTR, INT, INT, PTR, INT, PTR))); + events.set(PTR, PipeWireLibrary.REGISTRY_EVENTS_GLOBAL_REMOVE, + upcall("onGlobalRemove", MethodType.methodType(void.class, MemorySegment.class, int.class), + FunctionDescriptor.ofVoid(PTR, INT))); + + MethodHandle addListener = PipeWireLibrary.interfaceMethod(registry, + PipeWireLibrary.REGISTRY_METHOD_ADD_LISTENER, + FunctionDescriptor.of(INT, PTR, PTR, PTR, PTR)); + PipeWireLibrary.invoke(addListener, PipeWireLibrary.interfaceSelf(registry), + hook(), events, MemorySegment.NULL); + } + + /** A zeroed {@code struct spa_hook} for a listener; it must outlive the object it hooks. */ + private MemorySegment hook() { + return arena.allocate(PipeWireLibrary.SPA_HOOK_SIZEOF, 8); + } + + private MemorySegment upcall(String method, MethodType type, FunctionDescriptor descriptor) { + try { + MethodHandle handle = MethodHandles.lookup() + .bind(this, method, type) + .asType(descriptor.toMethodType()); + return PipeWireLibrary.LINKER.upcallStub(handle, descriptor, arena); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("cannot bind PipeWire callback " + method, e); + } + } + + // ---- callbacks, all invoked on the loop thread ---- + + @SuppressWarnings("unused") // bound as an upcall + private void onDone(MemorySegment data, int id, int seq) { + if (id != PipeWireLibrary.PW_ID_CORE || seq != pendingSync) return; + syncDone = true; + PipeWireLibrary.threadLoopSignal(threadLoop, false); + } + + @SuppressWarnings("unused") // bound as an upcall + private void onGlobal(MemorySegment data, int id, int permissions, MemorySegment type, + int version, MemorySegment props) { + try { + if (!PipeWireLibrary.TYPE_INTERFACE_NODE.equals(PipeWireLibrary.cString(type))) return; + if (props.address() == 0) return; + + Map properties = readDict(props); + String mediaClass = properties.get(PipeWireLibrary.KEY_MEDIA_CLASS); + boolean sink = "Audio/Sink".equals(mediaClass); + if (!sink && !"Audio/Source".equals(mediaClass)) return; + + String name = properties.get(PipeWireLibrary.KEY_NODE_NAME); + if (name == null || name.isBlank()) return; + + String description = firstNonBlank( + properties.get(PipeWireLibrary.KEY_NODE_DESCRIPTION), + properties.get(PipeWireLibrary.KEY_NODE_NICK), + name); + synchronized (nodes) { + nodes.put(id, new Node(name, description, sink)); + } + } catch (Throwable ignored) { + // An upcall must not throw: a node we cannot read is simply not listed. + } + } + + @SuppressWarnings("unused") // bound as an upcall + private void onGlobalRemove(MemorySegment data, int id) { + synchronized (nodes) { + nodes.remove(id); + } + } + + /** Copies a {@code struct spa_dict} into a Java map. */ + private static Map readDict(MemorySegment dict) { + MemorySegment header = PipeWireLibrary.at(dict, PipeWireLibrary.SPA_DICT_SIZE); + int itemCount = header.get(INT, PipeWireLibrary.SPA_DICT_N_ITEMS); + MemorySegment items = header.get(PTR, PipeWireLibrary.SPA_DICT_ITEMS); + Map properties = new LinkedHashMap<>(); + if (items.address() == 0 || itemCount <= 0) return properties; + + MemorySegment array = PipeWireLibrary.at(items, PipeWireLibrary.SPA_DICT_ITEM_SIZE * itemCount); + for (int i = 0; i < itemCount; i++) { + long offset = PipeWireLibrary.SPA_DICT_ITEM_SIZE * i; + String key = PipeWireLibrary.cString(array.get(PTR, offset)); + String value = PipeWireLibrary.cString( + array.get(PTR, offset + PipeWireLibrary.SPA_DICT_ITEM_VALUE)); + if (key != null) properties.put(key, value); + } + return properties; + } + + private static String firstNonBlank(String... candidates) { + for (String candidate : candidates) { + if (candidate != null && !candidate.isBlank()) return candidate; + } + return null; + } + + // ---- public surface ---- + + /** The audio sinks and sources currently in the graph. */ + List nodes() { + synchronized (nodes) { + return new ArrayList<>(nodes.values()); + } + } + + /** The loop streams are created on; only touch it under {@link #lock()}. */ + MemorySegment loop() { + return loop; + } + + void lock() { + PipeWireLibrary.threadLoopLock(threadLoop); + } + + void unlock() { + PipeWireLibrary.threadLoopUnlock(threadLoop); + } + + /** + * Waits until the server has processed everything sent so far, so that the initial + * burst of registry events has been delivered. Call with the loop lock held. + */ + private void roundtrip() { + pendingSync = (int) PipeWireLibrary.invoke(coreSync, + PipeWireLibrary.interfaceSelf(core), PipeWireLibrary.PW_ID_CORE, pendingSync + 1); + syncDone = false; + while (!syncDone) { + if (PipeWireLibrary.threadLoopTimedWait(threadLoop, ROUNDTRIP_TIMEOUT_SECONDS) != 0) break; + } + } + + private void close() { + if (threadLoop != null) PipeWireLibrary.threadLoopStop(threadLoop); + if (registry != null) PipeWireLibrary.proxyDestroy(registry); + if (core != null) PipeWireLibrary.coreDisconnect(core); + if (context != null) PipeWireLibrary.contextDestroy(context); + if (threadLoop != null) PipeWireLibrary.threadLoopDestroy(threadLoop); + } + + private static MemorySegment nonNull(MemorySegment pointer, String what) { + if (pointer == null || pointer.address() == 0) { + throw new IllegalStateException(what + " failed"); + } + return pointer; + } +} diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/pipewire/PipeWireStream.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/pipewire/PipeWireStream.java new file mode 100644 index 0000000..50f5601 --- /dev/null +++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/pipewire/PipeWireStream.java @@ -0,0 +1,222 @@ +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.LinkedHashMap; +import java.util.Map; + +import static com.ts3client.audio.desktop.pipewire.PipeWireLibrary.INT; +import static com.ts3client.audio.desktop.pipewire.PipeWireLibrary.PTR; + +/** + * One {@code pw_stream} plus the ring buffer that decouples it from the application. + * + *

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 properties = new LinkedHashMap<>(); + properties.put(PipeWireLibrary.KEY_MEDIA_TYPE, "Audio"); + properties.put(PipeWireLibrary.KEY_MEDIA_CATEGORY, capture ? "Capture" : "Playback"); + properties.put(PipeWireLibrary.KEY_MEDIA_ROLE, "Communication"); + properties.put(PipeWireLibrary.KEY_APP_NAME, "TS3J"); + properties.put(PipeWireLibrary.KEY_NODE_NAME, name); + properties.put(PipeWireLibrary.KEY_NODE_LATENCY, QUANTUM + "/" + rate); + properties.put(PipeWireLibrary.KEY_NODE_RATE, "1/" + rate); + if (targetNode != null && !targetNode.isEmpty()) { + properties.put(PipeWireLibrary.KEY_TARGET_OBJECT, targetNode); + } + return PipeWireLibrary.propertiesNewDict(dict(properties)); + } + + /** Builds a temporary {@code struct spa_dict}; {@code pw_properties_new_dict} copies it. */ + private MemorySegment dict(Map properties) { + MemorySegment items = arena.allocate( + PipeWireLibrary.SPA_DICT_ITEM_SIZE * properties.size(), 8); + int index = 0; + for (Map.Entry entry : properties.entrySet()) { + long offset = PipeWireLibrary.SPA_DICT_ITEM_SIZE * index++; + items.set(PTR, offset, arena.allocateFrom(entry.getKey())); + items.set(PTR, offset + PipeWireLibrary.SPA_DICT_ITEM_VALUE, + arena.allocateFrom(entry.getValue())); + } + MemorySegment dict = arena.allocate(PipeWireLibrary.SPA_DICT_SIZE, 8); + dict.set(INT, PipeWireLibrary.SPA_DICT_N_ITEMS, properties.size()); + dict.set(PTR, PipeWireLibrary.SPA_DICT_ITEMS, items); + return dict; + } + + private MemorySegment events() { + MemorySegment events = arena.allocate(PipeWireLibrary.STREAM_EVENTS_SIZEOF, 8); + events.set(INT, 0, PipeWireLibrary.PW_VERSION_STREAM_EVENTS); + events.set(PTR, PipeWireLibrary.STREAM_EVENTS_PROCESS, + upcall("onProcess", MethodType.methodType(void.class, MemorySegment.class), + FunctionDescriptor.ofVoid(PTR))); + events.set(PTR, PipeWireLibrary.STREAM_EVENTS_STATE_CHANGED, + upcall("onStateChanged", MethodType.methodType(void.class, MemorySegment.class, + int.class, int.class, MemorySegment.class), + FunctionDescriptor.ofVoid(PTR, INT, INT, PTR))); + return events; + } + + private MemorySegment upcall(String method, MethodType type, FunctionDescriptor descriptor) { + try { + MethodHandle handle = MethodHandles.lookup().bind(this, method, type); + return PipeWireLibrary.LINKER.upcallStub(handle, descriptor, arena); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("cannot bind PipeWire callback " + method, e); + } + } + + // ---- callbacks, invoked on the loop thread with the loop lock held ---- + + @SuppressWarnings("unused") // bound as an upcall + private void onStateChanged(MemorySegment data, int previous, int current, MemorySegment message) { + // A stream that errored will never be scheduled again, so unblock the pipeline + // instead of letting it wait for audio that cannot arrive. + if (current == PipeWireLibrary.PW_STREAM_STATE_ERROR) ring.close(); + } + + @SuppressWarnings("unused") // bound as an upcall + private void onProcess(MemorySegment data) { + try { + MemorySegment pwBuffer = PipeWireLibrary.streamDequeueBuffer(stream); + if (pwBuffer.address() == 0) return; // out of buffers; skip a cycle + try { + MemorySegment header = PipeWireLibrary.at(pwBuffer, PipeWireLibrary.PW_BUFFER_SIZEOF); + MemorySegment spaBuffer = PipeWireLibrary.at( + header.get(PTR, PipeWireLibrary.PW_BUFFER_BUFFER), PipeWireLibrary.SPA_BUFFER_SIZEOF); + if (spaBuffer.get(INT, PipeWireLibrary.SPA_BUFFER_N_DATAS) < 1) return; + + MemorySegment datum = PipeWireLibrary.at( + spaBuffer.get(PTR, PipeWireLibrary.SPA_BUFFER_DATAS), PipeWireLibrary.SPA_DATA_SIZEOF); + MemorySegment audio = datum.get(PTR, PipeWireLibrary.SPA_DATA_DATA); + if (audio.address() == 0) return; // not mapped (e.g. a DmaBuf) + + int maxSize = datum.get(INT, PipeWireLibrary.SPA_DATA_MAXSIZE); + MemorySegment chunk = PipeWireLibrary.at( + datum.get(PTR, PipeWireLibrary.SPA_DATA_CHUNK), PipeWireLibrary.SPA_CHUNK_SIZEOF); + process(header, PipeWireLibrary.at(audio, maxSize), chunk, maxSize); + } finally { + PipeWireLibrary.streamQueueBuffer(stream, pwBuffer); + } + } catch (Throwable ignored) { + // An upcall must not throw: drop the cycle rather than take the JVM down. + } + } + + /** + * Moves one buffer between {@code audio} and the ring. + * + * @param header the {@code struct pw_buffer}, for {@code requested} and {@code size} + * @param audio the mapped payload, {@code maxSize} bytes long + * @param chunk the {@code struct spa_chunk} describing the valid part of {@code audio} + */ + abstract void process(MemorySegment header, MemorySegment audio, MemorySegment chunk, int maxSize); + + // ---- lifecycle ---- + + public int channels() { + return channels; + } + + final int stride() { + return stride; + } + + public synchronized void start() { + if (started || closed) return; + started = true; + session.lock(); + try { + PipeWireLibrary.streamSetActive(stream, true); + } finally { + session.unlock(); + } + } + + public synchronized void close() { + if (closed) return; + closed = true; + ring.close(); + if (stream != null && stream.address() != 0) { + session.lock(); + try { + PipeWireLibrary.streamDestroy(stream); // disconnects and drops the listeners + } finally { + session.unlock(); + } + stream = null; + } + // Only safe once the loop can no longer reach the upcall stubs, i.e. after destroy. + arena.close(); + } +} diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/pipewire/SpaPod.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/pipewire/SpaPod.java new file mode 100644 index 0000000..f6e870e --- /dev/null +++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/pipewire/SpaPod.java @@ -0,0 +1,161 @@ +package com.ts3client.audio.desktop.pipewire; + +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SegmentAllocator; +import java.lang.foreign.ValueLayout; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +/** + * Builder for the SPA "plain old data" objects PipeWire uses to describe formats. + * + *

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 @@ 26 UTF-8 1.0.3 + 3.5.6 + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${surefire.version} + + + + + diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java index d9be3e7..57291d0 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java @@ -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 tabs = new ArrayList<>(); private final ServerTabPane tabPane = new ServerTabPane(this); 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 ab6b02f..20b3605 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 @@ -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 inputCombo; - private JComboBox outputCombo; + private JComboBox inputCombo; + private JComboBox 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 ins = AudioDevices.inputDeviceNames(); - List outs = AudioDevices.outputDeviceNames(); - ins.add(0, "(System default)"); - outs.add(0, "(System default)"); + List ins = AudioDevices.inputDevices(); + List 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 = "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); + 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 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 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) { - int idx = combo.getSelectedIndex(); - if (idx <= 0) return ""; - Object v = combo.getSelectedItem(); - return v == null ? "" : v.toString(); + private static String comboValue(JComboBox combo) { + AudioDevices.Device d = (AudioDevices.Device) combo.getSelectedItem(); + return d == null ? "" : d.id(); } }