Replace JNA with the FFM API (Panama) for the Opus binding

Bind libopus through java.lang.foreign downcall handles instead of JNA:
the ctl functions are linked with firstVariadicArg(2), and the encoder and
decoder each own a shared Arena holding the handle plus reusable native
PCM/packet buffers, so the hot path only allocates the returned packet.

The library is resolved by system SONAME first, falling back to a bundled
copy extracted from the JAR. Only the Windows x86-64 build is packaged by
default now (-Dnatives.all restores all platforms), since every other
platform ships libopus through its package manager.

Requires Java 26.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 23:35:57 +00:00
parent 022aef633b
commit 9d63522ef2
7 changed files with 381 additions and 100 deletions

View File

@@ -24,7 +24,7 @@ public final class JavaSoundAudioBackend implements AudioBackend {
@Override
public String description() {
try {
return "Opus " + Opus.INSTANCE.opus_get_version_string();
return "Opus " + Opus.getVersionString();
} catch (Throwable t) {
return "Opus (native library unavailable)";
}

View File

@@ -1,64 +1,283 @@
package com.ts3client.audio.desktop;
import com.sun.jna.Library;
import com.sun.jna.Native;
import com.sun.jna.ptr.PointerByReference;
import java.nio.IntBuffer;
import java.io.IOException;
import java.io.InputStream;
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;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
/**
* Minimal JNA binding to the native Opus codec library (libopus).
* Binding to the native Opus codec library (libopus) via the Foreign Function &amp;
* Memory API (project Panama).
*
* <p>{@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&nbsp;kHz which
* matches what the TeamSpeak 3 protocol uses on the wire.
* <p>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&nbsp;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<String> candidateNames() {
String os = System.getProperty("os.name", "").toLowerCase();
if (os.contains("win")) {
return List.of("opus.dll", "libopus-0.dll", "libopus.dll");
}
if (os.contains("mac") || os.contains("darwin")) {
return List.of("libopus.dylib", "libopus.0.dylib");
}
return List.of("libopus.so.0", "libopus.so");
}
private static Path extractBundled() {
String libraryName = System.mapLibraryName("opus");
for (String directory : platformDirectories()) {
String resource = directory + "/" + libraryName;
try (InputStream in = Opus.class.getClassLoader().getResourceAsStream(resource)) {
if (in == null) {
continue;
}
Path dir = Files.createTempDirectory("ts3-client-opus");
dir.toFile().deleteOnExit();
Path file = dir.resolve(libraryName);
Files.copy(in, file, StandardCopyOption.REPLACE_EXISTING);
file.toFile().deleteOnExit();
return file;
} catch (IOException e) {
throw new UnsatisfiedLinkError("failed to extract bundled libopus: " + e);
}
}
throw new UnsatisfiedLinkError("libopus not found on this system and no bundled copy for "
+ platformDirectories());
}
/**
* Classpath directories that may hold the bundled native (JNA's platform layout),
* most specific first — macOS also ships an architecture-agnostic {@code darwin/}.
*/
private static List<String> platformDirectories() {
String os = System.getProperty("os.name", "").toLowerCase();
String arch = System.getProperty("os.arch", "").toLowerCase();
String normalizedArch = switch (arch) {
case "amd64", "x86_64" -> "x86-64";
case "i386", "i486", "i586", "i686", "x86" -> "x86";
case "aarch64", "arm64" -> "aarch64";
default -> arch.startsWith("arm") ? "arm" : arch;
};
if (os.contains("win")) {
return List.of("win32-" + normalizedArch);
}
if (os.contains("mac") || os.contains("darwin")) {
return List.of("darwin-" + normalizedArch, "darwin");
}
return List.of("linux-" + normalizedArch);
}
}
}

View File

@@ -1,8 +1,8 @@
package com.ts3client.audio.desktop;
import com.sun.jna.ptr.PointerByReference;
import java.nio.IntBuffer;
import java.lang.foreign.Arena;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.ValueLayout;
/**
* Thin wrapper around a native Opus decoder.
@@ -13,7 +13,12 @@ import java.nio.IntBuffer;
*/
public final class OpusDecoder 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 packetBuffer;
private final MemorySegment pcmBuffer;
private final int frameSize;
private final int channels;
private boolean closed;
@@ -22,12 +27,17 @@ public final class OpusDecoder implements AutoCloseable {
this.frameSize = frameSize;
this.channels = channels;
IntBuffer error = IntBuffer.allocate(1);
handle = Opus.INSTANCE.opus_decoder_create(sampleRate, channels, error);
if (handle == null || error.get(0) != 0) {
throw new IllegalStateException("opus_decoder_create failed: "
+ Opus.INSTANCE.opus_strerror(error.get(0)));
MemorySegment error = arena.allocate(ValueLayout.JAVA_INT);
MemorySegment created = Opus.decoderCreate(sampleRate, channels, error);
int errorCode = error.get(ValueLayout.JAVA_INT, 0);
if (created.address() == 0 || errorCode != 0) {
arena.close();
throw new IllegalStateException("opus_decoder_create failed: " + Opus.strerror(errorCode));
}
this.handle = created;
this.packetBuffer = arena.allocate(MAX_PACKET_BYTES);
this.pcmBuffer = arena.allocate(ValueLayout.JAVA_FLOAT, (long) frameSize * channels);
}
/**
@@ -40,23 +50,29 @@ public final class OpusDecoder implements AutoCloseable {
*/
public int decode(byte[] packet, float[] out) {
if (closed) throw new IllegalStateException("decoder closed");
int samples = Opus.INSTANCE.opus_decode_float(
handle,
packet,
packet == null ? 0 : packet.length,
out,
frameSize,
0);
if (samples < 0) {
throw new IllegalStateException("opus_decode_float failed: "
+ Opus.INSTANCE.opus_strerror(samples));
MemorySegment data = MemorySegment.NULL;
int len = 0;
if (packet != null) {
if (packet.length > MAX_PACKET_BYTES) {
throw new IllegalArgumentException("packet too large: " + packet.length);
}
MemorySegment.copy(packet, 0, packetBuffer, ValueLayout.JAVA_BYTE, 0, packet.length);
data = packetBuffer;
len = packet.length;
}
int samples = Opus.decodeFloat(handle, data, len, pcmBuffer, frameSize, 0);
if (samples < 0) {
throw new IllegalStateException("opus_decode_float failed: " + Opus.strerror(samples));
}
MemorySegment.copy(pcmBuffer, ValueLayout.JAVA_FLOAT, 0, out, 0, samples * channels);
return samples;
}
public void reset() {
if (closed) return;
Opus.INSTANCE.opus_decoder_ctl(handle, Opus.OPUS_RESET_STATE);
Opus.decoderCtl(handle, Opus.OPUS_RESET_STATE, 0);
}
public int getChannels() {
@@ -67,6 +83,7 @@ public final class OpusDecoder implements AutoCloseable {
public void close() {
if (closed) return;
closed = true;
Opus.INSTANCE.opus_decoder_destroy(handle);
Opus.decoderDestroy(handle);
arena.close();
}
}

View File

@@ -1,21 +1,29 @@
package com.ts3client.audio.desktop;
import com.sun.jna.ptr.PointerByReference;
import java.nio.IntBuffer;
import java.lang.foreign.Arena;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.ValueLayout;
/**
* Thin wrapper around a native Opus encoder configured for TeamSpeak voice.
*
* <p>Fixed at 48&nbsp;kHz. Frames are 20&nbsp;ms (960 samples per channel), which
* is the frame size the TS3 client uses.
*
* <p>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();
}
}
}