Skip to content

seuffert/panopus

Repository files navigation

🛑 Read-Only Mirror

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

panopus

Pipeline Latest Release Maven Central Javadoc

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.


Requirements

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 opus

Custom library path

panopus 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.jar

When 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.jar

macOS

DYLD_LIBRARY_PATH=/opt/myapp/lib:$DYLD_LIBRARY_PATH java \
    --enable-native-access=org.seuffert.panopus -jar myapp.jar

Note (macOS): DYLD_LIBRARY_PATH is silently ignored for processes protected by System Integrity Protection (SIP). If that is a concern, use the panopus.libopus.path property instead.


Dependency

<!-- 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

Zero-copy path (direct ByteBuffer)

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 RTP

Both buffers must be direct (ByteBuffer.allocateDirect). Passing a heap ByteBuffer throws IllegalArgumentException.


Quick start

Checking availability

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.

Encoding

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 ...
}

Decoding

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);
}

Float pipeline

// 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);

Repacketizer (WebRTC SFU / jitter buffer)

// 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);
}

Packet inspection (no decoder needed)

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);

API overview

Opus

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

OpusEncoder

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

OpusDecoder

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

OpusRepacketizer

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

OpusPacket (static utilities)

getBandwidth, getNbChannels, getNbFrames, getNbSamples, getSamplesPerFrame, hasLbrr, parse, pad, unpad

Enums

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

OpusException

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.


Module system (JPMS)

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.


Frame size reference

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)

Thread safety

All wrapper classes (OpusEncoder, OpusDecoder, OpusRepacketizer) use Arena.ofShared() internally, which removes thread affinity from the underlying native memory. This means:

  1. 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();
  2. 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 synchronized block, a ReentrantLock, or a single-thread executor — is required if the instance is shared across threads that may call it concurrently.

  3. 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.


Building from source

git clone https://gitlab.com/org.seuffert/panopus.git
cd panopus
./gradlew :panopus:build

Running the test suite requires libopus to be installed on the system path.

./gradlew :panopus:test

License

Copyright 2026 Oliver Seuffert

Licensed under the Apache License, Version 2.0.

About

MIRROR ONLY - High-level Java bindings for libopus, built on top of the JDK 25 Panama Foreign Function & Memory (FFM) API.

Topics

Resources

License

Stars

Watchers

Forks

Contributors