This repository is a mirror. We do not accept Pull Requests or track Issues on GitHub.
All development, documentation, and community discussion takes place on GitLab.
👉 Click here to visit the official repository on GitLab
High-level Java bindings for libopus, built on top of the JDK 25 Panama Foreign Function & Memory (FFM) API.
panopus wraps the native libopus C library with a clean, idiomatic Java API — no JNI
boilerplate, no unsafe casts, no manual memory management. Every object that holds native
state implements AutoCloseable, so resources are released deterministically with
try-with-resources.
| Requirement | Version |
|---|---|
| JDK | 25+ |
| libopus | 1.3+ (system library, e.g. libopus.so / libopus.dylib) |
| OS | Linux, macOS (Windows support depends on libopus.dll being available) |
Installing libopus
# Debian / Ubuntu
sudo apt install libopus-dev
# Fedora / RHEL
sudo dnf install opus
# macOS
brew install opuspanopus loads libopus via the Panama FFM SymbolLookup.libraryLookup() call, which
delegates directly to the OS dynamic linker (dlopen on Linux/macOS). The JVM flag
-Djava.library.path has no effect here — it only applies to System.loadLibrary().
Option 1 — JVM system property (recommended)
Set panopus.libopus.path to the full path of the shared library file. This is the
cleanest, purely Java approach and works on all platforms:
java -Dpanopus.libopus.path=/opt/myapp/lib/libopus.so \
--enable-native-access=org.seuffert.panopus -jar myapp.jar# macOS
java -Dpanopus.libopus.path=/opt/myapp/lib/libopus.dylib \
--enable-native-access=org.seuffert.panopus -jar myapp.jarWhen this property is set, the OS-level library search is bypassed entirely —
the library at the given path is loaded directly, even if a different libopus
is installed system-wide.
Option 2 — OS-level mechanisms
Use these when you cannot control the JVM command line (e.g. inside an application server) or when you need the override to apply process-wide.
Linux
# Prepend the directory that contains your custom libopus.so
LD_LIBRARY_PATH=/opt/myapp/lib:$LD_LIBRARY_PATH java \
--enable-native-access=org.seuffert.panopus -jar myapp.jar
# Or preload the exact file — takes precedence even over the linker cache
LD_PRELOAD=/opt/myapp/lib/libopus.so java \
--enable-native-access=org.seuffert.panopus -jar myapp.jarmacOS
DYLD_LIBRARY_PATH=/opt/myapp/lib:$DYLD_LIBRARY_PATH java \
--enable-native-access=org.seuffert.panopus -jar myapp.jarNote (macOS):
DYLD_LIBRARY_PATHis silently ignored for processes protected by System Integrity Protection (SIP). If that is a concern, use thepanopus.libopus.pathproperty instead.
<!-- Maven -->
<dependency>
<groupId>org.seuffert</groupId>
<artifactId>panopus</artifactId>
<version>0.4.0</version>
</dependency>// Gradle (Kotlin DSL)
implementation("org.seuffert:panopus:0.4.0")panopus is a named JPMS module (org.seuffert.panopus). Add the following JVM flag
when running your application:
--enable-native-access=org.seuffert.panopus
Or, if your application is in the unnamed module:
--enable-native-access=ALL-UNNAMED
For high-throughput pipelines where packets already arrive in off-heap memory (NIO
SocketChannel, Netty, etc.) the standard overloads incur two heap copies per
call (Java array → native and native → Java array). The zero-copy overloads eliminate
both by working directly with direct ByteBuffers:
// Allocate once
ByteBuffer packetIn = ByteBuffer.allocateDirect(OpusEncoder.MAX_PACKET_BYTES);
ByteBuffer pcmOut = ByteBuffer.allocateDirect(dec.getMaxPcmFrameBytes());
// Decode loop — zero heap copies
while (running) {
// fill packetIn from network (e.g. channel.read(packetIn))
packetIn.flip();
int samples = dec.decode(packetIn, packetIn.remaining(), pcmOut, 960);
// consume pcmOut[0 .. samples * channels * 2) bytes
packetIn.clear();
pcmOut.clear();
}For encoding:
ByteBuffer pcmIn = ByteBuffer.allocateDirect(enc.getChannels() * 960 * Short.BYTES);
ByteBuffer packetOut = ByteBuffer.allocateDirect(OpusEncoder.MAX_PACKET_BYTES);
// fill pcmIn with interleaved 16-bit PCM samples
int written = enc.encode(pcmIn, 960, packetOut);
// send packetOut[0..written) over RTPBoth buffers must be direct (ByteBuffer.allocateDirect). Passing a heap
ByteBuffer throws IllegalArgumentException.
Before constructing an encoder or decoder, call Opus.isAvailable() to verify that
libopus is present on the current machine — particularly useful in optional-feature
paths or when shipping to environments where libopus may not be installed:
if (!Opus.isAvailable()) {
// libopus not found — disable audio features or use a fallback
return;
}
System.out.println(Opus.getVersion()); // "libopus 1.5.2"
try (OpusEncoder enc = new OpusEncoder(48_000, 2, OpusApplication.AUDIO)) { ... }Skipping this check when libopus is absent causes the first panopus call to throw
ExceptionInInitializerError — an Error subclass not caught by ordinary
catch (Exception e) blocks.
try (OpusEncoder enc = new OpusEncoder(48000, 2, OpusApplication.VOIP)) {
enc.setBitrate(32_000);
enc.setInbandFec(true);
enc.setPacketLossPerc(5);
// pcm: interleaved stereo, 960 samples/channel = 20 ms @ 48 kHz
short[] pcm = /* ... */;
byte[] packet = enc.encode(pcm, 960);
// send over RTP ...
}try (OpusDecoder dec = new OpusDecoder(48000, 2)) {
// normal packet received
short[] pcm = dec.decode(packet, dec.getNbSamples(packet));
// packet lost — generate concealment audio
short[] concealed = dec.decodePLC(960);
// packet lost but next packet with FEC data is available
short[] recovered = dec.decodeWithFec(nextPacket, 960);
}// Encoding from float PCM (e.g. Web Audio API output)
byte[] packet = enc.encodeFloat(floatPcm, 960);
// Decoding to float PCM
float[] pcm = dec.decodeFloat(packet, 960);
// Soft-clip to [-1.0, 1.0] after integer-to-float conversion
float[] clipped = dec.softClip(pcm, 960);// Aggregate three 20 ms frames into one 60 ms packet
try (OpusRepacketizer rpkt = new OpusRepacketizer()) {
rpkt.cat(frame1);
rpkt.cat(frame2);
rpkt.cat(frame3);
byte[] merged = rpkt.out();
}
// Split a 60 ms packet back into individual 20 ms frames
try (OpusRepacketizer rpkt = new OpusRepacketizer()) {
rpkt.cat(packet60ms);
byte[] f1 = rpkt.outRange(0, 1);
byte[] f2 = rpkt.outRange(1, 2);
byte[] f3 = rpkt.outRange(2, 3);
}OpusBandwidth bw = OpusPacket.getBandwidth(packet);
int ch = OpusPacket.getNbChannels(packet);
int nf = OpusPacket.getNbFrames(packet);
int ns = OpusPacket.getNbSamples(packet, 48000);
boolean lbrr = OpusPacket.hasLbrr(packet);
// Full packet parse (TOC byte + per-frame sizes)
ParsedPacket parsed = OpusPacket.parse(packet);
// Padding
byte[] padded = OpusPacket.pad(packet, 256);
byte[] unpadded = OpusPacket.unpad(padded);| Method | Description |
|---|---|
isAvailable() |
Returns true if libopus was found and loaded; false otherwise |
getVersion() |
libopus version string (e.g. "libopus 1.5.2") |
strerror(int) |
Human-readable description of a libopus error code |
| Method | Description |
|---|---|
encode(short[], int) |
Encode 16-bit PCM → Opus packet |
encodeFloat(float[], int) |
Encode float PCM → Opus packet |
encode(ByteBuffer, int, ByteBuffer) |
Zero-copy encode; both buffers must be direct; returns bytes written |
encodeFloat(ByteBuffer, int, ByteBuffer) |
Zero-copy float encode; returns bytes written |
setBitrate(int) / getBitrate() |
Target bitrate in bps; BITRATE_AUTO or BITRATE_MAX |
setComplexity(int) / getComplexity() |
Encoder complexity 0–10 |
setVbr(boolean) / isVbr() |
Variable bitrate |
setVbrConstraint(boolean) / isVbrConstrained() |
Constrained VBR |
setInbandFec(boolean) / isInbandFec() |
In-band Forward Error Correction |
setPacketLossPerc(int) / getPacketLossPerc() |
Expected packet loss % (drives FEC) |
setDtx(boolean) / isDtx() |
Discontinuous transmission |
isInDtx() |
Whether encoder is currently in DTX silence |
setApplication(OpusApplication) |
Switch application mode at runtime |
setSignal(OpusSignal) |
Signal type hint (voice / music / auto) |
setBandwidth(OpusBandwidth) / getBandwidth() |
Restrict encoded bandwidth |
setLsbDepth(int) / getLsbDepth() |
Input bit depth hint |
setExpertFrameDuration(OpusFrameSize) |
Force a specific frame duration |
reset() |
Reset encoder state without reallocation |
| Method | Description |
|---|---|
decode(byte[], int) |
Decode Opus packet → 16-bit PCM |
decodeFloat(byte[], int) |
Decode Opus packet → float PCM |
decode(ByteBuffer, int, ByteBuffer, int) |
Zero-copy decode; both buffers must be direct; returns samples per channel |
decodeFloat(ByteBuffer, int, ByteBuffer, int) |
Zero-copy float decode; returns samples per channel |
decodePLC(int) |
Packet Loss Concealment → 16-bit PCM |
decodeFloatPLC(int) |
Packet Loss Concealment → float PCM |
decodeWithFec(byte[], int) |
Recover lost frame from next packet's FEC data → 16-bit |
decodeFloatWithFec(byte[], int) |
Recover lost frame from next packet's FEC data → float |
decodeWithFec(ByteBuffer, int, ByteBuffer, int) |
Zero-copy FEC decode; returns samples per channel |
decodeFloatWithFec(ByteBuffer, int, ByteBuffer, int) |
Zero-copy float FEC decode; returns samples per channel |
getMaxPcmFrameBytes() |
Max 16-bit PCM output buffer size in bytes for this decoder's channel count |
getMaxPcmFrameFloatBytes() |
Max float PCM output buffer size in bytes for this decoder's channel count |
softClip(float[], int) |
Soft-clip float PCM to [-1.0, 1.0] |
getNbSamples(byte[]) |
Samples per channel in a packet at this decoder's rate |
setGain(int) / getGain() |
Output gain in Q8 dB units |
setPhaseInversionDisabled(boolean) / isPhaseInversionDisabled() |
Disable phase inversion (mono downmix compatibility) |
getLastBandwidth() |
Bandwidth of the last decoded packet |
getPitch() |
Pitch of the last decoded frame |
getLastPacketDuration() |
Duration of the last decoded packet in samples |
isInDtx() |
Whether decoder is in DTX silence |
getSampleRate() / getChannels() |
Configured decoder parameters |
reset() |
Reset decoder state |
| Method | Description |
|---|---|
cat(byte[]) |
Add a frame to the repacketizer buffer (max 48 frames) |
getNumFrames() |
Number of frames currently buffered |
out() |
Emit all buffered frames as a single packet |
outRange(int, int) |
Emit a sub-range [begin, end) of buffered frames |
reset() |
Clear the buffer for reuse |
getBandwidth, getNbChannels, getNbFrames, getNbSamples, getSamplesPerFrame,
hasLbrr, parse, pad, unpad
| Enum | Values |
|---|---|
OpusApplication |
VOIP, AUDIO, RESTRICTED_LOWDELAY |
OpusBandwidth |
NARROWBAND, MEDIUMBAND, WIDEBAND, SUPERWIDEBAND, FULLBAND, AUTO |
OpusSignal |
VOICE, MUSIC, AUTO |
OpusFrameSize |
MS_2_5, MS_5, MS_10, MS_20, MS_40, MS_60, MS_80, MS_100, MS_120, ARG |
RuntimeException thrown by any method when libopus returns an error. Carries an
errorCode field with the raw libopus error integer; use Opus.strerror(code) to get
the human-readable message.
panopus ships with a module-info.java:
module org.seuffert.panopus {
exports org.seuffert.panopus;
}The jextract-generated native bindings (org.seuffert.panopus.internal) are not
exported and not part of the public API.
| Samples @ 48 kHz | Duration |
|---|---|
| 120 | 2.5 ms |
| 240 | 5 ms |
| 480 | 10 ms |
| 960 | 20 ms ← most common for VoIP / WebRTC |
| 1920 | 40 ms |
| 2880 | 60 ms |
| 5760 | 120 ms (maximum) |
All wrapper classes (OpusEncoder, OpusDecoder, OpusRepacketizer) use
Arena.ofShared() internally, which removes thread affinity from the underlying
native memory. This means:
-
Create, use, and close may happen on different threads. The common real-time streaming pattern — init on a startup thread, decode on a network thread, close on a teardown thread — works without any extra coordination:
// Thread 1 — initialization OpusDecoder dec = new OpusDecoder(48000, 2); // Thread 2 — network / decode loop (after Thread 1 has finished constructing) short[] pcm = dec.decode(packet, 960); // Thread 3 — teardown (after Thread 2 has stopped using the instance) dec.close();
-
Concurrent calls on the same instance are not safe. libopus encoder/decoder state is inherently single-threaded. Two threads calling methods on the same instance simultaneously will silently corrupt internal state. External serialization — a
synchronizedblock, aReentrantLock, or a single-thread executor — is required if the instance is shared across threads that may call it concurrently. -
Multiple instances in parallel — safe. Each instance owns its own native resources. Any number of instances may be used concurrently on different threads without coordination.
git clone https://gitlab.com/org.seuffert/panopus.git
cd panopus
./gradlew :panopus:buildRunning the test suite requires libopus to be installed on the system path.
./gradlew :panopus:testCopyright 2026 Oliver Seuffert
Licensed under the Apache License, Version 2.0.