diff --git a/ts3-client/README.md b/ts3-client/README.md
index c96b1ac..067d8bb 100644
--- a/ts3-client/README.md
+++ b/ts3-client/README.md
@@ -10,8 +10,8 @@ TeaVM/CheerpJ) without touching the library:
| Module | Artifact | Responsibility |
|--------|----------|----------------|
-| `core` | `ts3-client-core` | Frontend-agnostic library: protocol integration, server model, connection orchestration, audio **abstractions**. No UI, no platform audio, no JNA. |
-| `desktop` | `ts3-client-desktop` | Desktop audio backend: Java Sound capture/playback + native Opus via JNA, implementing the core audio interfaces. |
+| `core` | `ts3-client-core` | Frontend-agnostic library: protocol integration, server model, connection orchestration, audio **abstractions**. No UI, no platform audio, no native code. |
+| `desktop` | `ts3-client-desktop` | Desktop audio backend: Java Sound capture/playback + native Opus via the FFM API (project Panama), implementing the core audio interfaces. |
| `swing` | `ts3-client-swing` | Swing desktop UI + entry point. Depends on `core` and `desktop`. |
The core exposes `AudioBackend` / `VoiceInput` / `VoiceOutput`; the frontend injects
@@ -21,8 +21,11 @@ frontend supplies its own UI and audio backend while reusing `core` unchanged.
## Features
### Voice (the priority)
-- **Native Opus codec** via a direct JNA binding to the system `libopus`
- (no bundled/native-jar dependency). Encoding at 48 kHz, 20 ms frames.
+- **Native Opus codec** via a direct Panama (Foreign Function & Memory API) binding —
+ no JNA, no other third-party FFI. The system `libopus` is used when installed,
+ otherwise a bundled copy is extracted from the JAR. Only the Windows x86-64 build
+ is bundled by default (build with `-Dnatives.all` to package every platform);
+ elsewhere install libopus from your package manager. Encoding at 48 kHz, 20 ms frames.
- **Voice Activation Detection (VAD)** with the same three modes as the TS3 client:
- **Volume Gate** — RMS/dBFS threshold with a live input meter and hangover.
- **Automatic** — a dependency-free speech detector (short-term energy + spectral
@@ -78,7 +81,7 @@ frontend supplies its own UI and audio backend while reusing `core` unchanged.
- Change your nickname, mute/deafen from the toolbar.
## Requirements
-- Java 17+ (developed/tested on Temurin 26)
+- Java 26+ (developed/tested on Temurin 26)
- The native Opus library on the system:
- Debian/Ubuntu: `sudo apt install libopus0`
- Arch: `sudo pacman -S opus`
@@ -124,7 +127,7 @@ core/ com.ts3client
└── ConnectionListener frontend callbacks
desktop/ com.ts3client.audio.desktop
-├── Opus JNA binding to native libopus
+├── Opus Panama (FFM) binding to native libopus
├── OpusEncoder/OpusDecoder thin codec wrappers
├── AudioDevices device enumeration + line opening (48 kHz/16-bit)
├── JavaSoundVoiceInput capture + VAD/PTT gating + Opus encode
diff --git a/ts3-client/desktop/pom.xml b/ts3-client/desktop/pom.xml
index a3e0ee3..7c15870 100644
--- a/ts3-client/desktop/pom.xml
+++ b/ts3-client/desktop/pom.xml
@@ -12,7 +12,7 @@
{@link Native#load} first tries a system-installed {@code libopus.so}/ - * {@code opus.dll}; failing that, JNA extracts and loads the copy we bundle on - * the classpath under its platform resource path (e.g. {@code linux-x86-64/libopus.so}, - * {@code win32-x86-64/opus.dll}), so the client runs from the single JAR with no - * external Opus install. Opus always operates internally at 48 kHz which - * matches what the TeamSpeak 3 protocol uses on the wire. + *
The library is resolved by first trying the system-installed {@code libopus}
+ * under its usual SONAMEs; failing that, the copy we bundle on the classpath for
+ * the running platform (e.g. {@code linux-x86-64/libopus.so}, {@code win32-x86-64/opus.dll})
+ * is extracted to a temporary file and loaded, so the client runs from the single
+ * JAR with no external Opus install. Opus always operates internally at 48 kHz
+ * which matches what the TeamSpeak 3 protocol uses on the wire.
*/
-public interface Opus extends Library {
+public final class Opus {
- Opus INSTANCE = Native.load("opus", Opus.class);
+ private Opus() {
+ }
// ---- application types (opus_defines.h) ----
- int OPUS_APPLICATION_VOIP = 2048;
- int OPUS_APPLICATION_AUDIO = 2049;
- int OPUS_APPLICATION_RESTRICTED_LOWDELAY = 2051;
+ public static final int OPUS_APPLICATION_VOIP = 2048;
+ public static final int OPUS_APPLICATION_AUDIO = 2049;
+ public static final int OPUS_APPLICATION_RESTRICTED_LOWDELAY = 2051;
// ---- CTL request codes ----
- int OPUS_SET_BITRATE_REQUEST = 4002;
- int OPUS_SET_VBR_REQUEST = 4006;
- int OPUS_SET_COMPLEXITY_REQUEST = 4010;
- int OPUS_SET_INBAND_FEC_REQUEST = 4012;
- int OPUS_SET_PACKET_LOSS_PERC_REQUEST = 4014;
- int OPUS_SET_SIGNAL_REQUEST = 4024;
- int OPUS_RESET_STATE = 4028;
+ public static final int OPUS_SET_BITRATE_REQUEST = 4002;
+ public static final int OPUS_SET_VBR_REQUEST = 4006;
+ public static final int OPUS_SET_COMPLEXITY_REQUEST = 4010;
+ public static final int OPUS_SET_INBAND_FEC_REQUEST = 4012;
+ public static final int OPUS_SET_PACKET_LOSS_PERC_REQUEST = 4014;
+ public static final int OPUS_SET_SIGNAL_REQUEST = 4024;
+ public static final int OPUS_RESET_STATE = 4028;
// ---- signal hints ----
- int OPUS_AUTO = -1000;
- int OPUS_SIGNAL_VOICE = 3001;
- int OPUS_SIGNAL_MUSIC = 3002;
+ public static final int OPUS_AUTO = -1000;
+ public static final int OPUS_SIGNAL_VOICE = 3001;
+ public static final int OPUS_SIGNAL_MUSIC = 3002;
- // ---- encoder ----
- PointerByReference opus_encoder_create(int fs, int channels, int application, IntBuffer error);
+ private static final ValueLayout.OfInt INT = ValueLayout.JAVA_INT;
+ private static final java.lang.foreign.AddressLayout PTR = ValueLayout.ADDRESS;
- int opus_encode_float(PointerByReference st, float[] pcm, int frameSize, byte[] data, int maxDataBytes);
+ private static final Linker LINKER = Linker.nativeLinker();
+ private static final SymbolLookup LOOKUP = NativeLibraries.loadOpus();
- int opus_encoder_ctl(PointerByReference st, int request, Object... args);
+ // OpusEncoder *opus_encoder_create(opus_int32 fs, int channels, int application, int *error)
+ private static final MethodHandle ENCODER_CREATE =
+ downcall("opus_encoder_create", FunctionDescriptor.of(PTR, INT, INT, INT, PTR));
+ // opus_int32 opus_encode_float(OpusEncoder *st, const float *pcm, int frame_size,
+ // unsigned char *data, opus_int32 max_data_bytes)
+ private static final MethodHandle ENCODE_FLOAT =
+ downcall("opus_encode_float", FunctionDescriptor.of(INT, PTR, PTR, INT, PTR, INT));
+ private static final MethodHandle ENCODER_DESTROY =
+ downcall("opus_encoder_destroy", FunctionDescriptor.ofVoid(PTR));
- void opus_encoder_destroy(PointerByReference st);
+ // OpusDecoder *opus_decoder_create(opus_int32 fs, int channels, int *error)
+ private static final MethodHandle DECODER_CREATE =
+ downcall("opus_decoder_create", FunctionDescriptor.of(PTR, INT, INT, PTR));
+ // int opus_decode_float(OpusDecoder *st, const unsigned char *data, opus_int32 len,
+ // float *pcm, int frame_size, int decode_fec)
+ private static final MethodHandle DECODE_FLOAT =
+ downcall("opus_decode_float", FunctionDescriptor.of(INT, PTR, PTR, INT, PTR, INT, INT));
+ private static final MethodHandle DECODER_DESTROY =
+ downcall("opus_decoder_destroy", FunctionDescriptor.ofVoid(PTR));
- // ---- decoder ----
- PointerByReference opus_decoder_create(int fs, int channels, IntBuffer error);
+ private static final MethodHandle STRERROR =
+ downcall("opus_strerror", FunctionDescriptor.of(PTR, INT));
+ private static final MethodHandle GET_VERSION_STRING =
+ downcall("opus_get_version_string", FunctionDescriptor.of(PTR));
- int opus_decode_float(PointerByReference st, byte[] data, int len, float[] pcm, int frameSize, int decodeFec);
+ // int opus_encoder_ctl(OpusEncoder *st, int request, ...) — the value argument is
+ // variadic, so the linker must be told where the variadic part starts (index 2).
+ private static final MethodHandle ENCODER_CTL_SET = variadicCtl("opus_encoder_ctl");
+ private static final MethodHandle DECODER_CTL_SET = variadicCtl("opus_decoder_ctl");
- int opus_decoder_ctl(PointerByReference st, int request, Object... args);
+ private static MethodHandle downcall(String symbol, FunctionDescriptor descriptor) {
+ return LINKER.downcallHandle(find(symbol), descriptor);
+ }
- void opus_decoder_destroy(PointerByReference st);
+ private static MethodHandle variadicCtl(String symbol) {
+ return LINKER.downcallHandle(
+ find(symbol),
+ FunctionDescriptor.of(INT, PTR, INT, INT),
+ Linker.Option.firstVariadicArg(2));
+ }
- // ---- misc ----
- String opus_get_version_string();
+ private static MemorySegment find(String symbol) {
+ return LOOKUP.find(symbol)
+ .orElseThrow(() -> new UnsatisfiedLinkError("libopus: unresolved symbol " + symbol));
+ }
- String opus_strerror(int error);
+ static MemorySegment encoderCreate(int sampleRate, int channels, int application, MemorySegment error) {
+ try {
+ return (MemorySegment) ENCODER_CREATE.invokeExact(sampleRate, channels, application, error);
+ } catch (Throwable t) {
+ throw wrap(t);
+ }
+ }
+
+ static int encodeFloat(MemorySegment st, MemorySegment pcm, int frameSize,
+ MemorySegment data, int maxDataBytes) {
+ try {
+ return (int) ENCODE_FLOAT.invokeExact(st, pcm, frameSize, data, maxDataBytes);
+ } catch (Throwable t) {
+ throw wrap(t);
+ }
+ }
+
+ static void encoderDestroy(MemorySegment st) {
+ try {
+ ENCODER_DESTROY.invokeExact(st);
+ } catch (Throwable t) {
+ throw wrap(t);
+ }
+ }
+
+ static int encoderCtl(MemorySegment st, int request, int value) {
+ try {
+ return (int) ENCODER_CTL_SET.invokeExact(st, request, value);
+ } catch (Throwable t) {
+ throw wrap(t);
+ }
+ }
+
+ static MemorySegment decoderCreate(int sampleRate, int channels, MemorySegment error) {
+ try {
+ return (MemorySegment) DECODER_CREATE.invokeExact(sampleRate, channels, error);
+ } catch (Throwable t) {
+ throw wrap(t);
+ }
+ }
+
+ static int decodeFloat(MemorySegment st, MemorySegment data, int len,
+ MemorySegment pcm, int frameSize, int decodeFec) {
+ try {
+ return (int) DECODE_FLOAT.invokeExact(st, data, len, pcm, frameSize, decodeFec);
+ } catch (Throwable t) {
+ throw wrap(t);
+ }
+ }
+
+ static void decoderDestroy(MemorySegment st) {
+ try {
+ DECODER_DESTROY.invokeExact(st);
+ } catch (Throwable t) {
+ throw wrap(t);
+ }
+ }
+
+ static int decoderCtl(MemorySegment st, int request, int value) {
+ try {
+ return (int) DECODER_CTL_SET.invokeExact(st, request, value);
+ } catch (Throwable t) {
+ throw wrap(t);
+ }
+ }
+
+ /** Human-readable message for an Opus error code. */
+ public static String strerror(int error) {
+ try {
+ MemorySegment message = (MemorySegment) STRERROR.invokeExact(error);
+ return cString(message);
+ } catch (Throwable t) {
+ throw wrap(t);
+ }
+ }
+
+ /** Version string of the loaded libopus, e.g. {@code libopus 1.4}. */
+ public static String getVersionString() {
+ try {
+ MemorySegment version = (MemorySegment) GET_VERSION_STRING.invokeExact();
+ return cString(version);
+ } catch (Throwable t) {
+ throw wrap(t);
+ }
+ }
+
+ /** Reads a NUL-terminated C string from a zero-length (unbounded) return segment. */
+ private static String cString(MemorySegment segment) {
+ if (segment.address() == 0) {
+ return null;
+ }
+ return segment.reinterpret(Long.MAX_VALUE).getString(0);
+ }
+
+ private static RuntimeException wrap(Throwable t) {
+ if (t instanceof RuntimeException re) {
+ return re;
+ }
+ if (t instanceof Error e) {
+ throw e;
+ }
+ return new IllegalStateException("libopus call failed", t);
+ }
+
+ /**
+ * Resolves libopus: system install first, bundled classpath copy second.
+ */
+ private static final class NativeLibraries {
+
+ static SymbolLookup loadOpus() {
+ Arena arena = Arena.global();
+ for (String name : candidateNames()) {
+ try {
+ return SymbolLookup.libraryLookup(name, arena);
+ } catch (IllegalArgumentException ignored) {
+ // not present under this name; try the next one
+ }
+ }
+ return SymbolLookup.libraryLookup(extractBundled(), arena);
+ }
+
+ /** SONAMEs/filenames a system-installed libopus may carry, in preference order. */
+ private static List Fixed at 48 kHz. Frames are 20 ms (960 samples per channel), which
* is the frame size the TS3 client uses.
+ *
+ * The native scratch buffers live in a shared arena owned by this instance and
+ * are freed by {@link #close()}; access is serialised by the instance lock, so the
+ * encoder may be driven from any thread.
*/
public final class OpusEncoder implements AutoCloseable {
- private final PointerByReference handle;
+ private static final int MAX_PACKET_BYTES = 4096;
+
+ private final Arena arena = Arena.ofShared();
+ private final MemorySegment handle;
+ private final MemorySegment pcmBuffer;
+ private final MemorySegment packetBuffer;
private final int frameSize;
private final int channels;
- private final byte[] out = new byte[4096];
private final Object lock = new Object();
private boolean closed;
@@ -23,12 +31,17 @@ public final class OpusEncoder implements AutoCloseable {
this.frameSize = frameSize;
this.channels = channels;
- IntBuffer error = IntBuffer.allocate(1);
- handle = Opus.INSTANCE.opus_encoder_create(sampleRate, channels, application, error);
- if (handle == null || error.get(0) != 0) {
- throw new IllegalStateException("opus_encoder_create failed: "
- + Opus.INSTANCE.opus_strerror(error.get(0)));
+ MemorySegment error = arena.allocate(ValueLayout.JAVA_INT);
+ MemorySegment created = Opus.encoderCreate(sampleRate, channels, application, error);
+ int errorCode = error.get(ValueLayout.JAVA_INT, 0);
+ if (created.address() == 0 || errorCode != 0) {
+ arena.close();
+ throw new IllegalStateException("opus_encoder_create failed: " + Opus.strerror(errorCode));
}
+
+ this.handle = created;
+ this.pcmBuffer = arena.allocate(ValueLayout.JAVA_FLOAT, (long) frameSize * channels);
+ this.packetBuffer = arena.allocate(MAX_PACKET_BYTES);
}
public void setBitrate(int bitsPerSecond) {
@@ -58,10 +71,10 @@ public final class OpusEncoder implements AutoCloseable {
private void ctl(int request, int value) {
synchronized (lock) {
if (closed) return;
- int r = Opus.INSTANCE.opus_encoder_ctl(handle, request, value);
+ int r = Opus.encoderCtl(handle, request, value);
if (r < 0) {
throw new IllegalStateException("opus_encoder_ctl(" + request + ") failed: "
- + Opus.INSTANCE.opus_strerror(r));
+ + Opus.strerror(r));
}
}
}
@@ -73,19 +86,19 @@ public final class OpusEncoder implements AutoCloseable {
* @return a newly allocated byte array holding the encoded packet
*/
public byte[] encode(float[] pcm) {
- if (pcm.length != frameSize * channels) {
- throw new IllegalArgumentException("expected " + (frameSize * channels)
- + " samples, got " + pcm.length);
+ int expected = frameSize * channels;
+ if (pcm.length != expected) {
+ throw new IllegalArgumentException("expected " + expected + " samples, got " + pcm.length);
}
synchronized (lock) {
if (closed) throw new IllegalStateException("encoder closed");
- int len = Opus.INSTANCE.opus_encode_float(handle, pcm, frameSize, out, out.length);
+ MemorySegment.copy(pcm, 0, pcmBuffer, ValueLayout.JAVA_FLOAT, 0, expected);
+ int len = Opus.encodeFloat(handle, pcmBuffer, frameSize, packetBuffer, MAX_PACKET_BYTES);
if (len < 0) {
- throw new IllegalStateException("opus_encode_float failed: "
- + Opus.INSTANCE.opus_strerror(len));
+ throw new IllegalStateException("opus_encode_float failed: " + Opus.strerror(len));
}
byte[] packet = new byte[len];
- System.arraycopy(out, 0, packet, 0, len);
+ MemorySegment.copy(packetBuffer, ValueLayout.JAVA_BYTE, 0, packet, 0, len);
return packet;
}
}
@@ -95,7 +108,8 @@ public final class OpusEncoder implements AutoCloseable {
synchronized (lock) {
if (closed) return;
closed = true;
- Opus.INSTANCE.opus_encoder_destroy(handle);
+ Opus.encoderDestroy(handle);
+ arena.close();
}
}
}
diff --git a/ts3-client/pom.xml b/ts3-client/pom.xml
index 1b7384a..5895cd1 100644
--- a/ts3-client/pom.xml
+++ b/ts3-client/pom.xml
@@ -19,10 +19,9 @@