Add a PipeWire audio backend and stereo capture/playback
Capture and playback now go through AudioCapture/AudioPlayback interfaces with two implementations: PipeWire, used for the system default and for every pw: device when a session is reachable, and Java Sound for the raw ALSA devices and other platforms. The PipeWire binding is FFM-based (PipeWireLibrary, PipeWireSession, PipeWireStream, SpaPod) and enumerates the graph's real devices, so each stream shows up separately in volume mixers. Lines are opened with a negotiated channel count instead of a fixed mono format: OPUS_MUSIC transmits the stereo capture as-is, OPUS_VOICE keeps transmitting the mono downmix so the pre-processing chain and VAD still see a single channel. Java Sound enumeration asks only for "a mixer that can capture/play" so the PipeWire and PulseAudio ALSA plugins are no longer hidden by their narrow format lists. The Java Sound classes are renamed to Desktop* to match what they now are, and JUnit plus a SpaPod round-trip test are wired into the build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
<artifactId>ts3-client-desktop</artifactId>
|
<artifactId>ts3-client-desktop</artifactId>
|
||||||
<name>TS3J Client Desktop Audio</name>
|
<name>TS3J Client Desktop Audio</name>
|
||||||
<description>Desktop audio backend: Java Sound capture/playback and native Opus via the FFM API</description>
|
<description>Desktop audio backend: PipeWire/Java Sound capture/playback and native Opus via the FFM API</description>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -23,6 +23,12 @@
|
|||||||
<groupId>com.github.manevolent</groupId>
|
<groupId>com.github.manevolent</groupId>
|
||||||
<artifactId>ts3j</artifactId>
|
<artifactId>ts3j</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<version>5.13.4</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.ts3client.audio.desktop;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An open microphone line, delivering 16-bit little-endian PCM at
|
||||||
|
* {@link AudioDevices#SAMPLE_RATE}.
|
||||||
|
*
|
||||||
|
* <p>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();
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.ts3client.audio.desktop;
|
package com.ts3client.audio.desktop;
|
||||||
|
|
||||||
|
import com.ts3client.audio.desktop.pipewire.PipeWire;
|
||||||
|
|
||||||
import javax.sound.sampled.AudioFormat;
|
import javax.sound.sampled.AudioFormat;
|
||||||
import javax.sound.sampled.AudioSystem;
|
import javax.sound.sampled.AudioSystem;
|
||||||
import javax.sound.sampled.DataLine;
|
import javax.sound.sampled.DataLine;
|
||||||
import javax.sound.sampled.Line;
|
import javax.sound.sampled.Line;
|
||||||
|
import javax.sound.sampled.LineUnavailableException;
|
||||||
import javax.sound.sampled.Mixer;
|
import javax.sound.sampled.Mixer;
|
||||||
import javax.sound.sampled.SourceDataLine;
|
import javax.sound.sampled.SourceDataLine;
|
||||||
import javax.sound.sampled.TargetDataLine;
|
import javax.sound.sampled.TargetDataLine;
|
||||||
@@ -11,74 +14,209 @@ import java.util.ArrayList;
|
|||||||
import java.util.List;
|
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.
|
||||||
*
|
*
|
||||||
* <p>The TS3 protocol uses 48 kHz, 16-bit, mono/stereo signed little-endian
|
* <p>The TS3 protocol uses 48 kHz 16-bit signed little-endian PCM for Opus, mono for
|
||||||
* PCM for Opus. We standardise on that format everywhere.
|
* {@code OPUS_VOICE} and stereo for {@code OPUS_MUSIC}, so the channel count is
|
||||||
|
* negotiated per line rather than fixed.
|
||||||
|
*
|
||||||
|
* <p>Two backends sit behind {@link AudioCapture} / {@link AudioPlayback}:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link PipeWire} — used for the system default and for every {@code pw:} device
|
||||||
|
* when a PipeWire session is reachable. It sees the graph's real devices and each
|
||||||
|
* stream shows up separately in volume mixers.</li>
|
||||||
|
* <li>Java Sound — everywhere else, and for the raw ALSA devices which stay in the
|
||||||
|
* list because they still work without PipeWire and can be opened exclusively.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>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 final class AudioDevices {
|
||||||
|
|
||||||
public static final int SAMPLE_RATE = 48_000;
|
public static final int SAMPLE_RATE = 48_000;
|
||||||
public static final int FRAME_SIZE = 960; // 20 ms @ 48 kHz
|
public static final int FRAME_SIZE = 960; // 20 ms @ 48 kHz
|
||||||
public static final AudioFormat CAPTURE_FORMAT =
|
public static final int MAX_CHANNELS = 2;
|
||||||
new AudioFormat(SAMPLE_RATE, 16, 1, true, false); // mono capture
|
|
||||||
public static final AudioFormat PLAYBACK_FORMAT =
|
/** Buffer size, in 20 ms frames, requested when opening a Java Sound line. */
|
||||||
new AudioFormat(SAMPLE_RATE, 16, 1, true, false); // mono playback
|
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() {
|
private AudioDevices() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Names of mixers that can provide microphone (capture) lines. */
|
/** 48 kHz signed 16-bit little-endian PCM with the given channel count. */
|
||||||
public static List<String> inputDeviceNames() {
|
public static AudioFormat format(int channels) {
|
||||||
return deviceNames(new DataLine.Info(TargetDataLine.class, CAPTURE_FORMAT));
|
return new AudioFormat(SAMPLE_RATE, 16, channels, true, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Names of mixers that can provide speaker (playback) lines. */
|
/** Devices that can provide microphone (capture) lines, system default first. */
|
||||||
public static List<String> outputDeviceNames() {
|
public static List<Device> inputDevices() {
|
||||||
return deviceNames(new DataLine.Info(SourceDataLine.class, PLAYBACK_FORMAT));
|
return devices(TargetDataLine.class, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<String> deviceNames(Line.Info lineInfo) {
|
/** Devices that can provide speaker (playback) lines, system default first. */
|
||||||
List<String> names = new ArrayList<>();
|
public static List<Device> outputDevices() {
|
||||||
|
return devices(SourceDataLine.class, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Device> devices(Class<?> lineClass, boolean sinks) {
|
||||||
|
List<Device> 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()) {
|
for (Mixer.Info mi : AudioSystem.getMixerInfo()) {
|
||||||
Mixer mixer = AudioSystem.getMixer(mi);
|
|
||||||
if (mixer.isLineSupported(lineInfo)) {
|
|
||||||
String name = mi.getName();
|
String name = mi.getName();
|
||||||
if (name != null && !names.contains(name)) {
|
if (name == null || name.isEmpty()) continue;
|
||||||
names.add(name);
|
// 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;
|
||||||
}
|
|
||||||
return names;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static TargetDataLine openCapture(String deviceName) throws Exception {
|
/**
|
||||||
DataLine.Info info = new DataLine.Info(TargetDataLine.class, CAPTURE_FORMAT);
|
* Java Sound names its ALSA {@code default} mixer after the PCM it opened, which under
|
||||||
Mixer.Info mixerInfo = findMixer(deviceName, info);
|
* pipewire-alsa is our own stream node (e.g. {@code alsa_playback.java [default]}), so
|
||||||
TargetDataLine line = (mixerInfo != null)
|
* only the bracketed device part is stable.
|
||||||
? (TargetDataLine) AudioSystem.getMixer(mixerInfo).getLine(info)
|
*/
|
||||||
: (TargetDataLine) AudioSystem.getLine(info);
|
private static boolean isDefaultMixer(String mixerName) {
|
||||||
line.open(CAPTURE_FORMAT, FRAME_SIZE * 2 * 8); // ~8 frame buffer
|
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 extends DataLine> T open(Class<T> 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;
|
return line;
|
||||||
|
} catch (Exception e) {
|
||||||
|
failure = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw unavailable(lineClass == TargetDataLine.class, deviceId, failure);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static SourceDataLine openPlayback(String deviceName) throws Exception {
|
private static LineUnavailableException unavailable(boolean capture, String deviceId,
|
||||||
DataLine.Info info = new DataLine.Info(SourceDataLine.class, PLAYBACK_FORMAT);
|
Exception failure) {
|
||||||
Mixer.Info mixerInfo = findMixer(deviceName, info);
|
LineUnavailableException e = new LineUnavailableException(
|
||||||
SourceDataLine line = (mixerInfo != null)
|
"No usable " + (capture ? "capture" : "playback") + " line for "
|
||||||
? (SourceDataLine) AudioSystem.getMixer(mixerInfo).getLine(info)
|
+ (isDefault(deviceId) ? "the system default" : deviceId));
|
||||||
: (SourceDataLine) AudioSystem.getLine(info);
|
if (failure != null) e.initCause(failure);
|
||||||
line.open(PLAYBACK_FORMAT, FRAME_SIZE * 2 * 8);
|
return e;
|
||||||
return line;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Mixer.Info findMixer(String deviceName, Line.Info lineInfo) {
|
/** {@code open(format, bufferSize)} lives on the two line interfaces, not on {@code DataLine}. */
|
||||||
if (deviceName == null || deviceName.isEmpty()) return null;
|
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()) {
|
for (Mixer.Info mi : AudioSystem.getMixerInfo()) {
|
||||||
if (deviceName.equals(mi.getName()) && AudioSystem.getMixer(mi).isLineSupported(lineInfo)) {
|
if (!deviceId.equals(mi.getName())) continue;
|
||||||
return mi;
|
Mixer mixer = AudioSystem.getMixer(mi);
|
||||||
}
|
if (mixer.isLineSupported(anyFormat)) return mixer;
|
||||||
}
|
}
|
||||||
return null; // fall back to system default
|
return null; // device went away: fall back to the system default
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.ts3client.audio.desktop;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An open speaker line, accepting 16-bit little-endian PCM at
|
||||||
|
* {@link AudioDevices#SAMPLE_RATE}.
|
||||||
|
*
|
||||||
|
* <p>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();
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,21 +7,26 @@ import com.ts3client.audio.SpeechDetector;
|
|||||||
import com.ts3client.audio.VoiceInput;
|
import com.ts3client.audio.VoiceInput;
|
||||||
import com.ts3client.config.Settings;
|
import com.ts3client.config.Settings;
|
||||||
|
|
||||||
import javax.sound.sampled.TargetDataLine;
|
|
||||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.function.Consumer;
|
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.
|
* or push-to-talk gating, and Opus-encodes 20 ms frames.
|
||||||
*
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
* <p>A dedicated capture thread fills a small packet queue while ts3j polls
|
* <p>A dedicated capture thread fills a small packet queue while ts3j polls
|
||||||
* {@link #isReady()} / {@link #provide()} on its own timer, decoupling capture from
|
* {@link #isReady()} / {@link #provide()} on its own timer, decoupling capture from
|
||||||
* network sending. When the gate closes and the queue drains {@link #isReady()}
|
* network sending. When the gate closes and the queue drains {@link #isReady()}
|
||||||
* returns {@code false}, prompting ts3j to emit the terminating empty voice packet.
|
* 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
|
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 final Object encoderLock = new Object();
|
||||||
private volatile OpusParameters params;
|
private volatile OpusParameters params;
|
||||||
|
private OpusParameters appliedParams;
|
||||||
private int encoderApplication = -1;
|
private int encoderApplication = -1;
|
||||||
|
private volatile int encoderChannels = 1;
|
||||||
|
private volatile int captureChannels = 1;
|
||||||
|
|
||||||
private Thread captureThread;
|
private Thread captureThread;
|
||||||
private TargetDataLine line;
|
private AudioCapture line;
|
||||||
private OpusEncoder encoder;
|
private OpusEncoder encoder;
|
||||||
|
|
||||||
private int hangover;
|
private int hangover;
|
||||||
private boolean lastTransmitting;
|
private boolean lastTransmitting;
|
||||||
|
|
||||||
public JavaSoundVoiceInput(Settings settings) {
|
public DesktopVoiceInput(Settings settings) {
|
||||||
this.deviceName = settings.inputDevice;
|
this.deviceName = settings.inputDevice;
|
||||||
this.mode = settings.inputMode;
|
this.mode = settings.inputMode;
|
||||||
this.vadMode = settings.vadMode;
|
this.vadMode = settings.vadMode;
|
||||||
@@ -67,7 +75,7 @@ public final class JavaSoundVoiceInput implements VoiceInput {
|
|||||||
this.vadOverPtt = settings.vadOverPtt;
|
this.vadOverPtt = settings.vadOverPtt;
|
||||||
this.inputGain = settings.inputVolume;
|
this.inputGain = settings.inputVolume;
|
||||||
this.params = OpusParameters.from(settings);
|
this.params = OpusParameters.from(settings);
|
||||||
this.codec = params.music ? CodecType.OPUS_MUSIC : CodecType.OPUS_VOICE;
|
this.codec = codecFor(params);
|
||||||
enhancer.setNoiseSuppression(settings.denoise);
|
enhancer.setNoiseSuppression(settings.denoise);
|
||||||
enhancer.setDenoiserLevel(settings.denoiserLevel);
|
enhancer.setDenoiserLevel(settings.denoiserLevel);
|
||||||
enhancer.setTypingAttenuation(settings.typingAttenuation);
|
enhancer.setTypingAttenuation(settings.typingAttenuation);
|
||||||
@@ -117,25 +125,16 @@ public final class JavaSoundVoiceInput implements VoiceInput {
|
|||||||
enhancer.setAgc(enabled);
|
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) {
|
public void setOpusParameters(OpusParameters p) {
|
||||||
this.params = p;
|
this.params = p;
|
||||||
this.codec = p.music ? CodecType.OPUS_MUSIC : CodecType.OPUS_VOICE;
|
|
||||||
synchronized (encoderLock) {
|
synchronized (encoderLock) {
|
||||||
if (encoder == null) return;
|
// While capturing, the codec flag flips together with the encoder swap so
|
||||||
int desiredApplication = applicationFor(p);
|
// no packet is ever tagged with a codec it wasn't encoded for.
|
||||||
if (desiredApplication != encoderApplication) {
|
if (encoder == null) this.codec = codecFor(p);
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +142,40 @@ public final class JavaSoundVoiceInput implements VoiceInput {
|
|||||||
return p.music ? Opus.OPUS_APPLICATION_AUDIO : Opus.OPUS_APPLICATION_VOIP;
|
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) {
|
private static void configureEncoder(OpusEncoder enc, OpusParameters p) {
|
||||||
enc.setBitrate(p.bitrate);
|
enc.setBitrate(p.bitrate);
|
||||||
enc.setComplexity(p.complexity);
|
enc.setComplexity(p.complexity);
|
||||||
@@ -173,13 +206,10 @@ public final class JavaSoundVoiceInput implements VoiceInput {
|
|||||||
public synchronized void start() {
|
public synchronized void start() {
|
||||||
if (running.get()) return;
|
if (running.get()) return;
|
||||||
try {
|
try {
|
||||||
line = AudioDevices.openCapture(deviceName);
|
line = AudioDevices.openCapture(deviceName, AudioDevices.MAX_CHANNELS);
|
||||||
OpusParameters p = params;
|
captureChannels = line.channels();
|
||||||
synchronized (encoderLock) {
|
synchronized (encoderLock) {
|
||||||
encoderApplication = applicationFor(p);
|
applyParams(params);
|
||||||
encoder = new OpusEncoder(
|
|
||||||
AudioDevices.SAMPLE_RATE, AudioDevices.FRAME_SIZE, 1, encoderApplication);
|
|
||||||
configureEncoder(encoder, p);
|
|
||||||
}
|
}
|
||||||
} catch (Throwable t) {
|
} catch (Throwable t) {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -206,11 +236,7 @@ public final class JavaSoundVoiceInput implements VoiceInput {
|
|||||||
|
|
||||||
private void cleanup() {
|
private void cleanup() {
|
||||||
if (line != null) {
|
if (line != null) {
|
||||||
try {
|
|
||||||
line.stop();
|
|
||||||
line.close();
|
line.close();
|
||||||
} catch (Exception ignored) {
|
|
||||||
}
|
|
||||||
line = null;
|
line = null;
|
||||||
}
|
}
|
||||||
synchronized (encoderLock) {
|
synchronized (encoderLock) {
|
||||||
@@ -221,32 +247,30 @@ public final class JavaSoundVoiceInput implements VoiceInput {
|
|||||||
}
|
}
|
||||||
encoder = null;
|
encoder = null;
|
||||||
encoderApplication = -1;
|
encoderApplication = -1;
|
||||||
|
appliedParams = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void captureLoop() {
|
private void captureLoop() {
|
||||||
final int frameSamples = AudioDevices.FRAME_SIZE;
|
final int frameSamples = AudioDevices.FRAME_SIZE;
|
||||||
final byte[] buf = new byte[frameSamples * 2];
|
final int channels = captureChannels;
|
||||||
final float[] pcm = new float[frameSamples];
|
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()) {
|
while (running.get()) {
|
||||||
int read = 0;
|
// A short read only happens once the line is closing, or on interruption.
|
||||||
try {
|
if (capture.read(buf, 0, buf.length) < buf.length) break;
|
||||||
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;
|
|
||||||
|
|
||||||
// 16-bit LE -> float, with input gain
|
// 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 lo = buf[2 * i] & 0xFF;
|
||||||
int hi = buf[2 * i + 1];
|
int hi = buf[2 * i + 1];
|
||||||
short s = (short) ((hi << 8) | lo);
|
short s = (short) ((hi << 8) | lo);
|
||||||
@@ -255,36 +279,50 @@ public final class JavaSoundVoiceInput implements VoiceInput {
|
|||||||
else if (f < -1f) f = -1f;
|
else if (f < -1f) f = -1f;
|
||||||
pcm[i] = f;
|
pcm[i] = f;
|
||||||
}
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Denoise / typing attenuation feed the level meter, VAD and encoder alike.
|
byte[] packet = null;
|
||||||
enhancer.process(pcm, frameSamples);
|
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;
|
double sumSq = 0;
|
||||||
for (int i = 0; i < frameSamples; i++) {
|
for (int i = 0; i < frameSamples; i++) {
|
||||||
sumSq += (double) pcm[i] * pcm[i];
|
sumSq += (double) mono[i] * mono[i];
|
||||||
}
|
}
|
||||||
double rms = Math.sqrt(sumSq / frameSamples);
|
double rms = Math.sqrt(sumSq / frameSamples);
|
||||||
double db = (rms <= 1e-9) ? -100.0 : 20.0 * Math.log10(rms);
|
double db = (rms <= 1e-9) ? -100.0 : 20.0 * Math.log10(rms);
|
||||||
Consumer<Double> ll = levelListener;
|
Consumer<Double> ll = levelListener;
|
||||||
if (ll != null) ll.accept(db);
|
if (ll != null) ll.accept(db);
|
||||||
|
|
||||||
boolean open = decideGate(db, pcm);
|
boolean open = decideGate(db, mono);
|
||||||
setTransmitting(open);
|
setTransmitting(open);
|
||||||
|
|
||||||
if (open && !muted.get()) {
|
if (open && !muted.get() && encoder != null) {
|
||||||
try {
|
try {
|
||||||
byte[] packet;
|
packet = encoder.encode(stereo ? pcm : mono);
|
||||||
synchronized (encoderLock) {
|
} catch (Exception ignored) {
|
||||||
packet = encoder != null ? encoder.encode(pcm) : null;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (packet != null && packet.length > 0) {
|
if (packet != null && packet.length > 0) {
|
||||||
queue.offer(packet);
|
queue.offer(packet);
|
||||||
// Guard against unbounded growth if the network stalls.
|
// Guard against unbounded growth if the network stalls.
|
||||||
while (queue.size() > 10) queue.poll();
|
while (queue.size() > 10) queue.poll();
|
||||||
}
|
}
|
||||||
} catch (Exception ignored) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
package com.ts3client.audio.desktop;
|
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.PacketBody0Voice;
|
||||||
import com.github.manevolent.ts3j.protocol.packet.PacketBody1VoiceWhisper;
|
import com.github.manevolent.ts3j.protocol.packet.PacketBody1VoiceWhisper;
|
||||||
import com.ts3client.audio.VoiceOutput;
|
import com.ts3client.audio.VoiceOutput;
|
||||||
|
|
||||||
import javax.sound.sampled.SourceDataLine;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
@@ -13,26 +13,35 @@ import java.util.concurrent.Executors;
|
|||||||
import java.util.function.BiConsumer;
|
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
|
* 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
|
* simultaneous speakers are mixed by the audio server and one slow decode never
|
||||||
* another (or the network thread).
|
* 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. */
|
/** One speaker's decode + playback pipeline. */
|
||||||
private final class ClientStream {
|
private final class ClientStream {
|
||||||
final int clientId;
|
final int clientId;
|
||||||
final OpusDecoder decoder;
|
final AudioPlayback line;
|
||||||
final SourceDataLine line;
|
final int lineChannels;
|
||||||
final ExecutorService worker;
|
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 boolean talking;
|
||||||
volatile long lastPacketNanos;
|
volatile long lastPacketNanos;
|
||||||
|
|
||||||
ClientStream(int clientId) throws Exception {
|
ClientStream(int clientId) throws Exception {
|
||||||
this.clientId = clientId;
|
this.clientId = clientId;
|
||||||
this.decoder = new OpusDecoder(AudioDevices.SAMPLE_RATE, AudioDevices.FRAME_SIZE, 1);
|
this.line = AudioDevices.openPlayback(outputDevice, AudioDevices.MAX_CHANNELS);
|
||||||
this.line = AudioDevices.openPlayback(outputDevice);
|
this.lineChannels = line.channels();
|
||||||
this.line.start();
|
this.line.start();
|
||||||
this.worker = Executors.newSingleThreadExecutor(r -> {
|
this.worker = Executors.newSingleThreadExecutor(r -> {
|
||||||
Thread t = new Thread(r, "ts3j-play-" + clientId);
|
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() {
|
void close() {
|
||||||
worker.shutdownNow();
|
worker.shutdownNow();
|
||||||
try {
|
|
||||||
line.stop();
|
|
||||||
line.close();
|
line.close();
|
||||||
} catch (Exception ignored) {
|
if (decoder != null) decoder.close();
|
||||||
}
|
|
||||||
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. */
|
/** Notified (clientId, talking) on the EDT-agnostic worker thread when a speaker starts/stops. */
|
||||||
private volatile BiConsumer<Integer, Boolean> talkListener;
|
private volatile BiConsumer<Integer, Boolean> talkListener;
|
||||||
|
|
||||||
public JavaSoundVoiceOutput(String outputDevice) {
|
public DesktopVoiceOutput(String outputDevice) {
|
||||||
this.outputDevice = outputDevice;
|
this.outputDevice = outputDevice;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,15 +122,20 @@ public final class JavaSoundVoiceOutput implements VoiceOutput {
|
|||||||
|
|
||||||
/** Entry point wired into {@code client.setVoiceHandler(...)}. */
|
/** Entry point wired into {@code client.setVoiceHandler(...)}. */
|
||||||
public void handleVoice(PacketBody0Voice voice) {
|
public void handleVoice(PacketBody0Voice voice) {
|
||||||
route(voice.getClientId(), voice.getCodecData());
|
route(voice.getClientId(), voice.getCodecType(), voice.getCodecData());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Entry point wired into {@code client.setWhisperHandler(...)}. */
|
/** Entry point wired into {@code client.setWhisperHandler(...)}. */
|
||||||
public void handleWhisper(PacketBody1VoiceWhisper whisper) {
|
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 (deafened) return;
|
||||||
if (mutedClients.contains(clientId)) return;
|
if (mutedClients.contains(clientId)) return;
|
||||||
|
|
||||||
@@ -139,32 +163,51 @@ public final class JavaSoundVoiceOutput implements VoiceOutput {
|
|||||||
target.line.drain();
|
target.line.drain();
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
}
|
}
|
||||||
target.decoder.reset();
|
if (target.decoder != null) target.decoder.reset();
|
||||||
markTalking(target, false);
|
markTalking(target, false);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
markTalking(target, true);
|
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 {
|
try {
|
||||||
float[] pcm = new float[AudioDevices.FRAME_SIZE];
|
float[] pcm = stream.pcm;
|
||||||
int samples = stream.decoder.decode(data, pcm);
|
int frames = stream.decoderFor(channels).decode(data, pcm);
|
||||||
double vol = masterVolume;
|
double vol = masterVolume;
|
||||||
|
|
||||||
byte[] out = new byte[samples * 2];
|
// Match the decoded stream to the line: duplicate mono across a stereo
|
||||||
for (int i = 0; i < samples; i++) {
|
// line, fold a stereo (music) stream down onto a mono-only line.
|
||||||
double v = pcm[i] * vol;
|
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;
|
if (v > 1.0) v = 1.0;
|
||||||
else if (v < -1.0) v = -1.0;
|
else if (v < -1.0) v = -1.0;
|
||||||
short s = (short) Math.round(v * 32767.0);
|
short s = (short) Math.round(v * 32767.0);
|
||||||
out[2 * i] = (byte) (s & 0xFF);
|
out[k] = (byte) (s & 0xFF);
|
||||||
out[2 * i + 1] = (byte) ((s >> 8) & 0xFF);
|
out[k + 1] = (byte) ((s >> 8) & 0xFF);
|
||||||
}
|
}
|
||||||
stream.line.write(out, 0, out.length);
|
}
|
||||||
|
stream.line.write(out, 0, bytes);
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,9 @@ import java.lang.foreign.ValueLayout;
|
|||||||
/**
|
/**
|
||||||
* Thin wrapper around a native Opus decoder.
|
* Thin wrapper around a native Opus decoder.
|
||||||
*
|
*
|
||||||
* <p>Created as mono at 48 kHz; Opus transparently down-mixes stereo streams
|
* <p>Fixed at 48 kHz. The channel count should match the stream being decoded
|
||||||
* (e.g. music-bot audio) to the requested channel count, so a single mono decoder
|
* (mono for {@code OPUS_VOICE}, stereo for {@code OPUS_MUSIC}); Opus will up-/down-mix
|
||||||
* copes with both {@code OPUS_VOICE} and {@code OPUS_MUSIC} payloads.
|
* a mismatched stream, which costs the stereo image.
|
||||||
*/
|
*/
|
||||||
public final class OpusDecoder implements AutoCloseable {
|
public final class OpusDecoder implements AutoCloseable {
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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<Node> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <h2>Interface methods</h2>
|
||||||
|
* 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}.
|
||||||
|
*
|
||||||
|
* <p>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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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<Integer, Node> 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<String, String> 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<String, String> 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<String, String> 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<Node> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>{@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<String, String> 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<String, String> properties) {
|
||||||
|
MemorySegment items = arena.allocate(
|
||||||
|
PipeWireLibrary.SPA_DICT_ITEM_SIZE * properties.size(), 8);
|
||||||
|
int index = 0;
|
||||||
|
for (Map.Entry<String, String> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,8 +22,21 @@
|
|||||||
<maven.compiler.release>26</maven.compiler.release>
|
<maven.compiler.release>26</maven.compiler.release>
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
<ts3j.version>1.0.3</ts3j.version>
|
<ts3j.version>1.0.3</ts3j.version>
|
||||||
|
<surefire.version>3.5.6</surefire.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<pluginManagement>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<version>${surefire.version}</version>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</pluginManagement>
|
||||||
|
</build>
|
||||||
|
|
||||||
<dependencyManagement>
|
<dependencyManagement>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.ts3client.ui;
|
package com.ts3client.ui;
|
||||||
|
|
||||||
import com.ts3client.audio.AudioBackend;
|
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.Bookmark;
|
||||||
import com.ts3client.config.Bookmarks;
|
import com.ts3client.config.Bookmarks;
|
||||||
import com.ts3client.config.IdentityStore;
|
import com.ts3client.config.IdentityStore;
|
||||||
@@ -49,7 +49,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
private final Settings settings;
|
private final Settings settings;
|
||||||
private final Bookmarks bookmarks = Bookmarks.load();
|
private final Bookmarks bookmarks = Bookmarks.load();
|
||||||
private final IdentityStore identities;
|
private final IdentityStore identities;
|
||||||
private final AudioBackend audio = new JavaSoundAudioBackend();
|
private final AudioBackend audio = new DesktopAudioBackend();
|
||||||
|
|
||||||
private final List<ServerTab> tabs = new ArrayList<>();
|
private final List<ServerTab> tabs = new ArrayList<>();
|
||||||
private final ServerTabPane tabPane = new ServerTabPane(this);
|
private final ServerTabPane tabPane = new ServerTabPane(this);
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ package com.ts3client.ui;
|
|||||||
import com.ts3client.audio.OpusParameters;
|
import com.ts3client.audio.OpusParameters;
|
||||||
import com.ts3client.audio.VoiceInput;
|
import com.ts3client.audio.VoiceInput;
|
||||||
import com.ts3client.audio.VoiceOutput;
|
import com.ts3client.audio.VoiceOutput;
|
||||||
|
import com.ts3client.audio.desktop.AudioCapture;
|
||||||
import com.ts3client.audio.desktop.AudioDevices;
|
import com.ts3client.audio.desktop.AudioDevices;
|
||||||
import com.ts3client.config.Settings;
|
import com.ts3client.config.Settings;
|
||||||
|
|
||||||
import javax.sound.sampled.TargetDataLine;
|
|
||||||
import javax.swing.BorderFactory;
|
import javax.swing.BorderFactory;
|
||||||
import javax.swing.Box;
|
import javax.swing.Box;
|
||||||
import javax.swing.ButtonGroup;
|
import javax.swing.ButtonGroup;
|
||||||
@@ -42,8 +42,8 @@ public final class SettingsDialog extends JDialog {
|
|||||||
private final VoiceOutput livePlayback;
|
private final VoiceOutput livePlayback;
|
||||||
private final Runnable onApply;
|
private final Runnable onApply;
|
||||||
|
|
||||||
private JComboBox<String> inputCombo;
|
private JComboBox<AudioDevices.Device> inputCombo;
|
||||||
private JComboBox<String> outputCombo;
|
private JComboBox<AudioDevices.Device> outputCombo;
|
||||||
private JSlider inputGain;
|
private JSlider inputGain;
|
||||||
private JSlider outputVol;
|
private JSlider outputVol;
|
||||||
private JCheckBox denoiseCheck;
|
private JCheckBox denoiseCheck;
|
||||||
@@ -123,16 +123,20 @@ public final class SettingsDialog extends JDialog {
|
|||||||
p.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
|
p.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
|
||||||
GridBagConstraints c = gbc();
|
GridBagConstraints c = gbc();
|
||||||
|
|
||||||
List<String> ins = AudioDevices.inputDeviceNames();
|
List<AudioDevices.Device> ins = AudioDevices.inputDevices();
|
||||||
List<String> outs = AudioDevices.outputDeviceNames();
|
List<AudioDevices.Device> outs = AudioDevices.outputDevices();
|
||||||
ins.add(0, "(System default)");
|
|
||||||
outs.add(0, "(System default)");
|
|
||||||
|
|
||||||
inputCombo = new JComboBox<>(ins.toArray(new String[0]));
|
inputCombo = new JComboBox<>(ins.toArray(new AudioDevices.Device[0]));
|
||||||
outputCombo = new JComboBox<>(outs.toArray(new String[0]));
|
outputCombo = new JComboBox<>(outs.toArray(new AudioDevices.Device[0]));
|
||||||
selectOrDefault(inputCombo, settings.inputDevice);
|
selectOrDefault(inputCombo, settings.inputDevice);
|
||||||
selectOrDefault(outputCombo, settings.outputDevice);
|
selectOrDefault(outputCombo, settings.outputDevice);
|
||||||
|
|
||||||
|
String deviceHint = "<html>Named devices are PipeWire's, and are routed through it "
|
||||||
|
+ "(so per-application volume and rerouting keep working).<br>"
|
||||||
|
+ "<b>ALSA:</b> entries talk to the sound card directly, taking it exclusively.</html>";
|
||||||
|
inputCombo.setToolTipText(deviceHint);
|
||||||
|
outputCombo.setToolTipText(deviceHint);
|
||||||
|
|
||||||
int row = 0;
|
int row = 0;
|
||||||
addRow(p, c, row++, new JLabel("Capture device (microphone):"), inputCombo);
|
addRow(p, c, row++, new JLabel("Capture device (microphone):"), inputCombo);
|
||||||
addRow(p, c, row++, new JLabel("Playback device (speakers):"), outputCombo);
|
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);
|
vbrCheck = new JCheckBox("Variable bitrate (VBR)", settings.vbr);
|
||||||
fecCheck = new JCheckBox("Forward error correction (FEC)", settings.fec);
|
fecCheck = new JCheckBox("Forward error correction (FEC)", settings.fec);
|
||||||
musicCheck = new JCheckBox("Music codec (higher fidelity)", settings.music);
|
musicCheck = new JCheckBox("Music codec (stereo, higher fidelity)", settings.music);
|
||||||
|
musicCheck.setToolTipText("<html>Transmits <b>OPUS_MUSIC</b>: stereo when the capture "
|
||||||
|
+ "device has two channels, and without the voice pre-processing "
|
||||||
|
+ "(noise removal, typing attenuation, AGC).<br>"
|
||||||
|
+ "Voice mode (<b>OPUS_VOICE</b>) is mono, as in the official client.</html>");
|
||||||
vbrCheck.addActionListener(e -> pushOpusLive());
|
vbrCheck.addActionListener(e -> pushOpusLive());
|
||||||
fecCheck.addActionListener(e -> pushOpusLive());
|
fecCheck.addActionListener(e -> pushOpusLive());
|
||||||
musicCheck.addActionListener(e -> pushOpusLive());
|
musicCheck.addActionListener(e -> pushOpusLive());
|
||||||
@@ -505,25 +513,26 @@ public final class SettingsDialog extends JDialog {
|
|||||||
|
|
||||||
private void meterLoop() {
|
private void meterLoop() {
|
||||||
String device = comboValue(inputCombo);
|
String device = comboValue(inputCombo);
|
||||||
TargetDataLine line = null;
|
AudioCapture line = null;
|
||||||
try {
|
try {
|
||||||
line = AudioDevices.openCapture(device);
|
line = AudioDevices.openCapture(device, AudioDevices.MAX_CHANNELS);
|
||||||
line.start();
|
line.start();
|
||||||
int frame = AudioDevices.FRAME_SIZE;
|
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);
|
double gain = (inputGain != null ? inputGain.getValue() / 100.0 : 1.0);
|
||||||
while (meterRunning) {
|
while (meterRunning) {
|
||||||
int read = 0;
|
if (line.read(buf, 0, buf.length) < buf.length) break;
|
||||||
while (read < buf.length) {
|
// Metering follows the capture chain: level is taken off the downmix.
|
||||||
int n = line.read(buf, read, buf.length - read);
|
|
||||||
if (n <= 0) break;
|
|
||||||
read += n;
|
|
||||||
}
|
|
||||||
if (read < buf.length) break;
|
|
||||||
double sumSq = 0;
|
double sumSq = 0;
|
||||||
for (int i = 0; i < frame; i++) {
|
for (int i = 0; i < frame; i++) {
|
||||||
short s = (short) ((buf[2 * i + 1] << 8) | (buf[2 * i] & 0xFF));
|
double mono = 0;
|
||||||
double f = s / 32768.0 * gain;
|
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;
|
sumSq += f * f;
|
||||||
}
|
}
|
||||||
double rms = Math.sqrt(sumSq / frame);
|
double rms = Math.sqrt(sumSq / frame);
|
||||||
@@ -533,13 +542,7 @@ public final class SettingsDialog extends JDialog {
|
|||||||
}
|
}
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
} finally {
|
} finally {
|
||||||
if (line != null) {
|
if (line != null) line.close();
|
||||||
try {
|
|
||||||
line.stop();
|
|
||||||
line.close();
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,24 +567,20 @@ public final class SettingsDialog extends JDialog {
|
|||||||
p.add(field, c);
|
p.add(field, c);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void selectOrDefault(JComboBox<String> combo, String value) {
|
private static void selectOrDefault(JComboBox<AudioDevices.Device> combo, String deviceId) {
|
||||||
if (value == null || value.isEmpty()) {
|
if (deviceId != null && !deviceId.isEmpty()) {
|
||||||
combo.setSelectedIndex(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (int i = 0; i < combo.getItemCount(); i++) {
|
for (int i = 0; i < combo.getItemCount(); i++) {
|
||||||
if (value.equals(combo.getItemAt(i))) {
|
if (deviceId.equals(combo.getItemAt(i).id())) {
|
||||||
combo.setSelectedIndex(i);
|
combo.setSelectedIndex(i);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
combo.setSelectedIndex(0);
|
combo.setSelectedIndex(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String comboValue(JComboBox<String> combo) {
|
private static String comboValue(JComboBox<AudioDevices.Device> combo) {
|
||||||
int idx = combo.getSelectedIndex();
|
AudioDevices.Device d = (AudioDevices.Device) combo.getSelectedItem();
|
||||||
if (idx <= 0) return "";
|
return d == null ? "" : d.id();
|
||||||
Object v = combo.getSelectedItem();
|
|
||||||
return v == null ? "" : v.toString();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user