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:
@@ -10,8 +10,8 @@ TeaVM/CheerpJ) without touching the library:
|
|||||||
|
|
||||||
| Module | Artifact | Responsibility |
|
| 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. |
|
| `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 JNA, implementing the core audio interfaces. |
|
| `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`. |
|
| `swing` | `ts3-client-swing` | Swing desktop UI + entry point. Depends on `core` and `desktop`. |
|
||||||
|
|
||||||
The core exposes `AudioBackend` / `VoiceInput` / `VoiceOutput`; the frontend injects
|
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
|
## Features
|
||||||
|
|
||||||
### Voice (the priority)
|
### Voice (the priority)
|
||||||
- **Native Opus codec** via a direct JNA binding to the system `libopus`
|
- **Native Opus codec** via a direct Panama (Foreign Function & Memory API) binding —
|
||||||
(no bundled/native-jar dependency). Encoding at 48 kHz, 20 ms frames.
|
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:
|
- **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.
|
- **Volume Gate** — RMS/dBFS threshold with a live input meter and hangover.
|
||||||
- **Automatic** — a dependency-free speech detector (short-term energy + spectral
|
- **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.
|
- Change your nickname, mute/deafen from the toolbar.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
- Java 17+ (developed/tested on Temurin 26)
|
- Java 26+ (developed/tested on Temurin 26)
|
||||||
- The native Opus library on the system:
|
- The native Opus library on the system:
|
||||||
- Debian/Ubuntu: `sudo apt install libopus0`
|
- Debian/Ubuntu: `sudo apt install libopus0`
|
||||||
- Arch: `sudo pacman -S opus`
|
- Arch: `sudo pacman -S opus`
|
||||||
@@ -124,7 +127,7 @@ core/ com.ts3client
|
|||||||
└── ConnectionListener frontend callbacks
|
└── ConnectionListener frontend callbacks
|
||||||
|
|
||||||
desktop/ com.ts3client.audio.desktop
|
desktop/ com.ts3client.audio.desktop
|
||||||
├── Opus JNA binding to native libopus
|
├── Opus Panama (FFM) binding to native libopus
|
||||||
├── OpusEncoder/OpusDecoder thin codec wrappers
|
├── OpusEncoder/OpusDecoder thin codec wrappers
|
||||||
├── AudioDevices device enumeration + line opening (48 kHz/16-bit)
|
├── AudioDevices device enumeration + line opening (48 kHz/16-bit)
|
||||||
├── JavaSoundVoiceInput capture + VAD/PTT gating + Opus encode
|
├── JavaSoundVoiceInput capture + VAD/PTT gating + Opus encode
|
||||||
|
|||||||
@@ -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 JNA</description>
|
<description>Desktop audio backend: Java Sound capture/playback and native Opus via the FFM API</description>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -23,9 +23,43 @@
|
|||||||
<groupId>com.github.manevolent</groupId>
|
<groupId>com.github.manevolent</groupId>
|
||||||
<artifactId>ts3j</artifactId>
|
<artifactId>ts3j</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
|
||||||
<groupId>net.java.dev.jna</groupId>
|
|
||||||
<artifactId>jna</artifactId>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<!--
|
||||||
|
Only the Windows x86-64 Opus build is packaged by default: every other
|
||||||
|
supported platform ships libopus through its package manager (or, on
|
||||||
|
macOS, Homebrew), and the FFM binding loads the system library first.
|
||||||
|
Build with -Dnatives.all to bundle the copies for all platforms.
|
||||||
|
-->
|
||||||
|
<resources>
|
||||||
|
<resource>
|
||||||
|
<directory>src/main/resources</directory>
|
||||||
|
<excludes>
|
||||||
|
<exclude>darwin/**</exclude>
|
||||||
|
<exclude>darwin-*/**</exclude>
|
||||||
|
<exclude>linux-*/**</exclude>
|
||||||
|
<exclude>win32-x86/**</exclude>
|
||||||
|
</excludes>
|
||||||
|
</resource>
|
||||||
|
</resources>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
<profiles>
|
||||||
|
<profile>
|
||||||
|
<id>all-natives</id>
|
||||||
|
<activation>
|
||||||
|
<property>
|
||||||
|
<name>natives.all</name>
|
||||||
|
</property>
|
||||||
|
</activation>
|
||||||
|
<build>
|
||||||
|
<resources>
|
||||||
|
<resource>
|
||||||
|
<directory>src/main/resources</directory>
|
||||||
|
</resource>
|
||||||
|
</resources>
|
||||||
|
</build>
|
||||||
|
</profile>
|
||||||
|
</profiles>
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public final class JavaSoundAudioBackend implements AudioBackend {
|
|||||||
@Override
|
@Override
|
||||||
public String description() {
|
public String description() {
|
||||||
try {
|
try {
|
||||||
return "Opus " + Opus.INSTANCE.opus_get_version_string();
|
return "Opus " + Opus.getVersionString();
|
||||||
} catch (Throwable t) {
|
} catch (Throwable t) {
|
||||||
return "Opus (native library unavailable)";
|
return "Opus (native library unavailable)";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +1,283 @@
|
|||||||
package com.ts3client.audio.desktop;
|
package com.ts3client.audio.desktop;
|
||||||
|
|
||||||
import com.sun.jna.Library;
|
import java.io.IOException;
|
||||||
import com.sun.jna.Native;
|
import java.io.InputStream;
|
||||||
import com.sun.jna.ptr.PointerByReference;
|
import java.lang.foreign.Arena;
|
||||||
|
import java.lang.foreign.FunctionDescriptor;
|
||||||
import java.nio.IntBuffer;
|
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 &
|
||||||
|
* Memory API (project Panama).
|
||||||
*
|
*
|
||||||
* <p>{@link Native#load} first tries a system-installed {@code libopus.so}/
|
* <p>The library is resolved by first trying the system-installed {@code libopus}
|
||||||
* {@code opus.dll}; failing that, JNA extracts and loads the copy we bundle on
|
* under its usual SONAMEs; failing that, the copy we bundle on the classpath for
|
||||||
* the classpath under its platform resource path (e.g. {@code linux-x86-64/libopus.so},
|
* the running platform (e.g. {@code linux-x86-64/libopus.so}, {@code win32-x86-64/opus.dll})
|
||||||
* {@code win32-x86-64/opus.dll}), so the client runs from the single JAR with no
|
* is extracted to a temporary file and loaded, so the client runs from the single
|
||||||
* external Opus install. Opus always operates internally at 48 kHz which
|
* JAR with no external Opus install. Opus always operates internally at 48 kHz
|
||||||
* matches what the TeamSpeak 3 protocol uses on the wire.
|
* 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) ----
|
// ---- application types (opus_defines.h) ----
|
||||||
int OPUS_APPLICATION_VOIP = 2048;
|
public static final int OPUS_APPLICATION_VOIP = 2048;
|
||||||
int OPUS_APPLICATION_AUDIO = 2049;
|
public static final int OPUS_APPLICATION_AUDIO = 2049;
|
||||||
int OPUS_APPLICATION_RESTRICTED_LOWDELAY = 2051;
|
public static final int OPUS_APPLICATION_RESTRICTED_LOWDELAY = 2051;
|
||||||
|
|
||||||
// ---- CTL request codes ----
|
// ---- CTL request codes ----
|
||||||
int OPUS_SET_BITRATE_REQUEST = 4002;
|
public static final int OPUS_SET_BITRATE_REQUEST = 4002;
|
||||||
int OPUS_SET_VBR_REQUEST = 4006;
|
public static final int OPUS_SET_VBR_REQUEST = 4006;
|
||||||
int OPUS_SET_COMPLEXITY_REQUEST = 4010;
|
public static final int OPUS_SET_COMPLEXITY_REQUEST = 4010;
|
||||||
int OPUS_SET_INBAND_FEC_REQUEST = 4012;
|
public static final int OPUS_SET_INBAND_FEC_REQUEST = 4012;
|
||||||
int OPUS_SET_PACKET_LOSS_PERC_REQUEST = 4014;
|
public static final int OPUS_SET_PACKET_LOSS_PERC_REQUEST = 4014;
|
||||||
int OPUS_SET_SIGNAL_REQUEST = 4024;
|
public static final int OPUS_SET_SIGNAL_REQUEST = 4024;
|
||||||
int OPUS_RESET_STATE = 4028;
|
public static final int OPUS_RESET_STATE = 4028;
|
||||||
|
|
||||||
// ---- signal hints ----
|
// ---- signal hints ----
|
||||||
int OPUS_AUTO = -1000;
|
public static final int OPUS_AUTO = -1000;
|
||||||
int OPUS_SIGNAL_VOICE = 3001;
|
public static final int OPUS_SIGNAL_VOICE = 3001;
|
||||||
int OPUS_SIGNAL_MUSIC = 3002;
|
public static final int OPUS_SIGNAL_MUSIC = 3002;
|
||||||
|
|
||||||
// ---- encoder ----
|
private static final ValueLayout.OfInt INT = ValueLayout.JAVA_INT;
|
||||||
PointerByReference opus_encoder_create(int fs, int channels, int application, IntBuffer error);
|
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 ----
|
private static final MethodHandle STRERROR =
|
||||||
PointerByReference opus_decoder_create(int fs, int channels, IntBuffer error);
|
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 ----
|
private static MemorySegment find(String symbol) {
|
||||||
String opus_get_version_string();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.ts3client.audio.desktop;
|
package com.ts3client.audio.desktop;
|
||||||
|
|
||||||
import com.sun.jna.ptr.PointerByReference;
|
import java.lang.foreign.Arena;
|
||||||
|
import java.lang.foreign.MemorySegment;
|
||||||
import java.nio.IntBuffer;
|
import java.lang.foreign.ValueLayout;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thin wrapper around a native Opus decoder.
|
* Thin wrapper around a native Opus decoder.
|
||||||
@@ -13,7 +13,12 @@ import java.nio.IntBuffer;
|
|||||||
*/
|
*/
|
||||||
public final class OpusDecoder implements AutoCloseable {
|
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 frameSize;
|
||||||
private final int channels;
|
private final int channels;
|
||||||
private boolean closed;
|
private boolean closed;
|
||||||
@@ -22,12 +27,17 @@ public final class OpusDecoder implements AutoCloseable {
|
|||||||
this.frameSize = frameSize;
|
this.frameSize = frameSize;
|
||||||
this.channels = channels;
|
this.channels = channels;
|
||||||
|
|
||||||
IntBuffer error = IntBuffer.allocate(1);
|
MemorySegment error = arena.allocate(ValueLayout.JAVA_INT);
|
||||||
handle = Opus.INSTANCE.opus_decoder_create(sampleRate, channels, error);
|
MemorySegment created = Opus.decoderCreate(sampleRate, channels, error);
|
||||||
if (handle == null || error.get(0) != 0) {
|
int errorCode = error.get(ValueLayout.JAVA_INT, 0);
|
||||||
throw new IllegalStateException("opus_decoder_create failed: "
|
if (created.address() == 0 || errorCode != 0) {
|
||||||
+ Opus.INSTANCE.opus_strerror(error.get(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) {
|
public int decode(byte[] packet, float[] out) {
|
||||||
if (closed) throw new IllegalStateException("decoder closed");
|
if (closed) throw new IllegalStateException("decoder closed");
|
||||||
int samples = Opus.INSTANCE.opus_decode_float(
|
|
||||||
handle,
|
MemorySegment data = MemorySegment.NULL;
|
||||||
packet,
|
int len = 0;
|
||||||
packet == null ? 0 : packet.length,
|
if (packet != null) {
|
||||||
out,
|
if (packet.length > MAX_PACKET_BYTES) {
|
||||||
frameSize,
|
throw new IllegalArgumentException("packet too large: " + packet.length);
|
||||||
0);
|
}
|
||||||
if (samples < 0) {
|
MemorySegment.copy(packet, 0, packetBuffer, ValueLayout.JAVA_BYTE, 0, packet.length);
|
||||||
throw new IllegalStateException("opus_decode_float failed: "
|
data = packetBuffer;
|
||||||
+ Opus.INSTANCE.opus_strerror(samples));
|
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;
|
return samples;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void reset() {
|
public void reset() {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
Opus.INSTANCE.opus_decoder_ctl(handle, Opus.OPUS_RESET_STATE);
|
Opus.decoderCtl(handle, Opus.OPUS_RESET_STATE, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public int getChannels() {
|
public int getChannels() {
|
||||||
@@ -67,6 +83,7 @@ public final class OpusDecoder implements AutoCloseable {
|
|||||||
public void close() {
|
public void close() {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
closed = true;
|
closed = true;
|
||||||
Opus.INSTANCE.opus_decoder_destroy(handle);
|
Opus.decoderDestroy(handle);
|
||||||
|
arena.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,29 @@
|
|||||||
package com.ts3client.audio.desktop;
|
package com.ts3client.audio.desktop;
|
||||||
|
|
||||||
import com.sun.jna.ptr.PointerByReference;
|
import java.lang.foreign.Arena;
|
||||||
|
import java.lang.foreign.MemorySegment;
|
||||||
import java.nio.IntBuffer;
|
import java.lang.foreign.ValueLayout;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thin wrapper around a native Opus encoder configured for TeamSpeak voice.
|
* Thin wrapper around a native Opus encoder configured for TeamSpeak voice.
|
||||||
*
|
*
|
||||||
* <p>Fixed at 48 kHz. Frames are 20 ms (960 samples per channel), which
|
* <p>Fixed at 48 kHz. Frames are 20 ms (960 samples per channel), which
|
||||||
* is the frame size the TS3 client uses.
|
* 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 {
|
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 frameSize;
|
||||||
private final int channels;
|
private final int channels;
|
||||||
private final byte[] out = new byte[4096];
|
|
||||||
private final Object lock = new Object();
|
private final Object lock = new Object();
|
||||||
private boolean closed;
|
private boolean closed;
|
||||||
|
|
||||||
@@ -23,12 +31,17 @@ public final class OpusEncoder implements AutoCloseable {
|
|||||||
this.frameSize = frameSize;
|
this.frameSize = frameSize;
|
||||||
this.channels = channels;
|
this.channels = channels;
|
||||||
|
|
||||||
IntBuffer error = IntBuffer.allocate(1);
|
MemorySegment error = arena.allocate(ValueLayout.JAVA_INT);
|
||||||
handle = Opus.INSTANCE.opus_encoder_create(sampleRate, channels, application, error);
|
MemorySegment created = Opus.encoderCreate(sampleRate, channels, application, error);
|
||||||
if (handle == null || error.get(0) != 0) {
|
int errorCode = error.get(ValueLayout.JAVA_INT, 0);
|
||||||
throw new IllegalStateException("opus_encoder_create failed: "
|
if (created.address() == 0 || errorCode != 0) {
|
||||||
+ Opus.INSTANCE.opus_strerror(error.get(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) {
|
public void setBitrate(int bitsPerSecond) {
|
||||||
@@ -58,10 +71,10 @@ public final class OpusEncoder implements AutoCloseable {
|
|||||||
private void ctl(int request, int value) {
|
private void ctl(int request, int value) {
|
||||||
synchronized (lock) {
|
synchronized (lock) {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
int r = Opus.INSTANCE.opus_encoder_ctl(handle, request, value);
|
int r = Opus.encoderCtl(handle, request, value);
|
||||||
if (r < 0) {
|
if (r < 0) {
|
||||||
throw new IllegalStateException("opus_encoder_ctl(" + request + ") failed: "
|
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
|
* @return a newly allocated byte array holding the encoded packet
|
||||||
*/
|
*/
|
||||||
public byte[] encode(float[] pcm) {
|
public byte[] encode(float[] pcm) {
|
||||||
if (pcm.length != frameSize * channels) {
|
int expected = frameSize * channels;
|
||||||
throw new IllegalArgumentException("expected " + (frameSize * channels)
|
if (pcm.length != expected) {
|
||||||
+ " samples, got " + pcm.length);
|
throw new IllegalArgumentException("expected " + expected + " samples, got " + pcm.length);
|
||||||
}
|
}
|
||||||
synchronized (lock) {
|
synchronized (lock) {
|
||||||
if (closed) throw new IllegalStateException("encoder closed");
|
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) {
|
if (len < 0) {
|
||||||
throw new IllegalStateException("opus_encode_float failed: "
|
throw new IllegalStateException("opus_encode_float failed: " + Opus.strerror(len));
|
||||||
+ Opus.INSTANCE.opus_strerror(len));
|
|
||||||
}
|
}
|
||||||
byte[] packet = new byte[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;
|
return packet;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,7 +108,8 @@ public final class OpusEncoder implements AutoCloseable {
|
|||||||
synchronized (lock) {
|
synchronized (lock) {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
closed = true;
|
closed = true;
|
||||||
Opus.INSTANCE.opus_encoder_destroy(handle);
|
Opus.encoderDestroy(handle);
|
||||||
|
arena.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,9 @@
|
|||||||
</modules>
|
</modules>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<maven.compiler.release>17</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>
|
||||||
<jna.version>5.14.0</jna.version>
|
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencyManagement>
|
<dependencyManagement>
|
||||||
@@ -32,11 +31,6 @@
|
|||||||
<artifactId>ts3j</artifactId>
|
<artifactId>ts3j</artifactId>
|
||||||
<version>${ts3j.version}</version>
|
<version>${ts3j.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
|
||||||
<groupId>net.java.dev.jna</groupId>
|
|
||||||
<artifactId>jna</artifactId>
|
|
||||||
<version>${jna.version}</version>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.ts3client</groupId>
|
<groupId>com.ts3client</groupId>
|
||||||
<artifactId>ts3-client-core</artifactId>
|
<artifactId>ts3-client-core</artifactId>
|
||||||
|
|||||||
Reference in New Issue
Block a user