Initial commit: TS3J TeamSpeak 3 Java client

Swing desktop client (core/desktop/swing Maven modules) built on the
ts3j protocol library, included as a submodule. Native Opus voice with
voice-activation detection, push-to-talk, and audio pre-processing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 22:01:17 +00:00
commit 0f76258a05
48 changed files with 6347 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ts3client</groupId>
<artifactId>ts3-client-parent</artifactId>
<version>0.1.0</version>
</parent>
<artifactId>ts3-client-desktop</artifactId>
<name>TS3J Client Desktop Audio</name>
<description>Desktop audio backend: Java Sound capture/playback and native Opus via JNA</description>
<dependencies>
<dependency>
<groupId>com.ts3client</groupId>
<artifactId>ts3-client-core</artifactId>
</dependency>
<dependency>
<groupId>com.github.manevolent</groupId>
<artifactId>ts3j</artifactId>
</dependency>
<dependency>
<groupId>net.java.dev.jna</groupId>
<artifactId>jna</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,84 @@
package com.ts3client.audio.desktop;
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.DataLine;
import javax.sound.sampled.Line;
import javax.sound.sampled.Mixer;
import javax.sound.sampled.SourceDataLine;
import javax.sound.sampled.TargetDataLine;
import java.util.ArrayList;
import java.util.List;
/**
* Helpers for enumerating and opening capture/playback lines by mixer name.
*
* <p>The TS3 protocol uses 48&nbsp;kHz, 16-bit, mono/stereo signed little-endian
* PCM for Opus. We standardise on that format everywhere.
*/
public final class AudioDevices {
public static final int SAMPLE_RATE = 48_000;
public static final int FRAME_SIZE = 960; // 20 ms @ 48 kHz
public static final AudioFormat CAPTURE_FORMAT =
new AudioFormat(SAMPLE_RATE, 16, 1, true, false); // mono capture
public static final AudioFormat PLAYBACK_FORMAT =
new AudioFormat(SAMPLE_RATE, 16, 1, true, false); // mono playback
private AudioDevices() {
}
/** Names of mixers that can provide microphone (capture) lines. */
public static List<String> inputDeviceNames() {
return deviceNames(new DataLine.Info(TargetDataLine.class, CAPTURE_FORMAT));
}
/** Names of mixers that can provide speaker (playback) lines. */
public static List<String> outputDeviceNames() {
return deviceNames(new DataLine.Info(SourceDataLine.class, PLAYBACK_FORMAT));
}
private static List<String> deviceNames(Line.Info lineInfo) {
List<String> names = new ArrayList<>();
for (Mixer.Info mi : AudioSystem.getMixerInfo()) {
Mixer mixer = AudioSystem.getMixer(mi);
if (mixer.isLineSupported(lineInfo)) {
String name = mi.getName();
if (name != null && !names.contains(name)) {
names.add(name);
}
}
}
return names;
}
public static TargetDataLine openCapture(String deviceName) throws Exception {
DataLine.Info info = new DataLine.Info(TargetDataLine.class, CAPTURE_FORMAT);
Mixer.Info mixerInfo = findMixer(deviceName, info);
TargetDataLine line = (mixerInfo != null)
? (TargetDataLine) AudioSystem.getMixer(mixerInfo).getLine(info)
: (TargetDataLine) AudioSystem.getLine(info);
line.open(CAPTURE_FORMAT, FRAME_SIZE * 2 * 8); // ~8 frame buffer
return line;
}
public static SourceDataLine openPlayback(String deviceName) throws Exception {
DataLine.Info info = new DataLine.Info(SourceDataLine.class, PLAYBACK_FORMAT);
Mixer.Info mixerInfo = findMixer(deviceName, info);
SourceDataLine line = (mixerInfo != null)
? (SourceDataLine) AudioSystem.getMixer(mixerInfo).getLine(info)
: (SourceDataLine) AudioSystem.getLine(info);
line.open(PLAYBACK_FORMAT, FRAME_SIZE * 2 * 8);
return line;
}
private static Mixer.Info findMixer(String deviceName, Line.Info lineInfo) {
if (deviceName == null || deviceName.isEmpty()) return null;
for (Mixer.Info mi : AudioSystem.getMixerInfo()) {
if (deviceName.equals(mi.getName()) && AudioSystem.getMixer(mi).isLineSupported(lineInfo)) {
return mi;
}
}
return null; // fall back to system default
}
}

View File

@@ -0,0 +1,32 @@
package com.ts3client.audio.desktop;
import com.ts3client.audio.AudioBackend;
import com.ts3client.audio.VoiceInput;
import com.ts3client.audio.VoiceOutput;
import com.ts3client.config.Settings;
/**
* Desktop audio backend using Java Sound for capture/playback and native Opus
* (via JNA) for the codec.
*/
public final class JavaSoundAudioBackend implements AudioBackend {
@Override
public VoiceInput createInput(Settings settings) {
return new JavaSoundVoiceInput(settings);
}
@Override
public VoiceOutput createOutput(Settings settings) {
return new JavaSoundVoiceOutput(settings.outputDevice);
}
@Override
public String description() {
try {
return "Opus " + Opus.INSTANCE.opus_get_version_string();
} catch (Throwable t) {
return "Opus (native library unavailable)";
}
}
}

View File

@@ -0,0 +1,371 @@
package com.ts3client.audio.desktop;
import com.github.manevolent.ts3j.enums.CodecType;
import com.ts3client.audio.AudioEnhancer;
import com.ts3client.audio.OpusParameters;
import com.ts3client.audio.SpeechDetector;
import com.ts3client.audio.VoiceInput;
import com.ts3client.config.Settings;
import javax.sound.sampled.TargetDataLine;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
/**
* Java Sound {@link VoiceInput}: captures the microphone, applies voice-activation
* or push-to-talk gating, and Opus-encodes 20&nbsp;ms frames.
*
* <p>A dedicated capture thread fills a small packet queue while ts3j polls
* {@link #isReady()} / {@link #provide()} on its own timer, decoupling capture from
* network sending. When the gate closes and the queue drains {@link #isReady()}
* returns {@code false}, prompting ts3j to emit the terminating empty voice packet.
*/
public final class JavaSoundVoiceInput implements VoiceInput {
private static final int HANGOVER_FRAMES = 15; // ~300 ms of tail after level drops
private final ConcurrentLinkedQueue<byte[]> queue = new ConcurrentLinkedQueue<>();
private final AtomicBoolean muted = new AtomicBoolean(false);
private final AtomicBoolean transmitting = new AtomicBoolean(false);
private final AtomicBoolean pttDown = new AtomicBoolean(false);
private final AtomicBoolean running = new AtomicBoolean(false);
private volatile Settings.InputMode mode;
private volatile Settings.VadMode vadMode;
private volatile double thresholdDb;
private volatile double speechThreshold;
private volatile boolean vadOverPtt;
private volatile double inputGain;
private volatile CodecType codec = CodecType.OPUS_VOICE;
private final SpeechDetector speechDetector = new SpeechDetector(AudioDevices.SAMPLE_RATE);
private final AudioEnhancer enhancer = new AudioEnhancer(AudioDevices.SAMPLE_RATE);
private volatile Consumer<Double> levelListener; // input level in dBFS
private volatile Consumer<Boolean> talkListener; // local talk-state changes
private final String deviceName;
private final Object encoderLock = new Object();
private volatile OpusParameters params;
private int encoderApplication = -1;
private Thread captureThread;
private TargetDataLine line;
private OpusEncoder encoder;
private int hangover;
private boolean lastTransmitting;
public JavaSoundVoiceInput(Settings settings) {
this.deviceName = settings.inputDevice;
this.mode = settings.inputMode;
this.vadMode = settings.vadMode;
this.thresholdDb = settings.vadThresholdDb;
this.speechThreshold = settings.speechThreshold;
this.vadOverPtt = settings.vadOverPtt;
this.inputGain = settings.inputVolume;
this.params = OpusParameters.from(settings);
this.codec = params.music ? CodecType.OPUS_MUSIC : CodecType.OPUS_VOICE;
enhancer.setNoiseSuppression(settings.denoise);
enhancer.setDenoiserLevel(settings.denoiserLevel);
enhancer.setTypingAttenuation(settings.typingAttenuation);
enhancer.setAgc(settings.agc);
}
// ---- live configuration (safe to call from the UI thread) ----
public void setMode(Settings.InputMode mode) {
this.mode = mode;
}
public void setVadMode(Settings.VadMode mode) {
this.vadMode = mode;
speechDetector.reset();
}
public void setThresholdDb(double db) {
this.thresholdDb = db;
}
public void setSpeechThreshold(double threshold) {
this.speechThreshold = threshold;
}
public void setVadOverPtt(boolean enabled) {
this.vadOverPtt = enabled;
}
public void setInputGain(double gain) {
this.inputGain = gain;
}
public void setNoiseSuppression(boolean enabled) {
enhancer.setNoiseSuppression(enabled);
}
public void setDenoiserLevel(double level) {
enhancer.setDenoiserLevel(level);
}
public void setTypingAttenuation(boolean enabled) {
enhancer.setTypingAttenuation(enabled);
}
public void setAgc(boolean enabled) {
enhancer.setAgc(enabled);
}
public void setOpusParameters(OpusParameters p) {
this.params = p;
this.codec = p.music ? CodecType.OPUS_MUSIC : CodecType.OPUS_VOICE;
synchronized (encoderLock) {
if (encoder == null) return;
int desiredApplication = applicationFor(p);
if (desiredApplication != encoderApplication) {
// The Opus application (VOIP vs AUDIO) can only be chosen at
// creation, so switching voice<->music requires a new encoder.
OpusEncoder replacement = new OpusEncoder(
AudioDevices.SAMPLE_RATE, AudioDevices.FRAME_SIZE, 1, desiredApplication);
configureEncoder(replacement, p);
OpusEncoder previous = encoder;
encoder = replacement;
encoderApplication = desiredApplication;
previous.close();
} else {
configureEncoder(encoder, p);
}
}
}
private static int applicationFor(OpusParameters p) {
return p.music ? Opus.OPUS_APPLICATION_AUDIO : Opus.OPUS_APPLICATION_VOIP;
}
private static void configureEncoder(OpusEncoder enc, OpusParameters p) {
enc.setBitrate(p.bitrate);
enc.setComplexity(p.complexity);
enc.setVbr(p.vbr);
enc.setInbandFec(p.fec);
enc.setExpectedPacketLoss(p.expectedPacketLoss);
enc.setSignal(p.music ? Opus.OPUS_SIGNAL_MUSIC : Opus.OPUS_SIGNAL_VOICE);
}
public void setLevelListener(Consumer<Double> l) {
this.levelListener = l;
}
public void setTalkListener(Consumer<Boolean> l) {
this.talkListener = l;
}
public void setPushToTalk(boolean down) {
this.pttDown.set(down);
}
public void setMuted(boolean m) {
this.muted.set(m);
}
// ---- lifecycle ----
public synchronized void start() {
if (running.get()) return;
try {
line = AudioDevices.openCapture(deviceName);
OpusParameters p = params;
synchronized (encoderLock) {
encoderApplication = applicationFor(p);
encoder = new OpusEncoder(
AudioDevices.SAMPLE_RATE, AudioDevices.FRAME_SIZE, 1, encoderApplication);
configureEncoder(encoder, p);
}
} catch (Throwable t) {
cleanup();
throw new RuntimeException("Could not start microphone: " + t.getMessage(), t);
}
speechDetector.reset();
enhancer.reset();
running.set(true);
captureThread = new Thread(this::captureLoop, "ts3j-mic-capture");
captureThread.setDaemon(true);
captureThread.start();
}
public synchronized void stop() {
running.set(false);
if (captureThread != null) {
captureThread.interrupt();
captureThread = null;
}
cleanup();
queue.clear();
setTransmitting(false);
}
private void cleanup() {
if (line != null) {
try {
line.stop();
line.close();
} catch (Exception ignored) {
}
line = null;
}
synchronized (encoderLock) {
if (encoder != null) {
try {
encoder.close();
} catch (Exception ignored) {
}
encoder = null;
encoderApplication = -1;
}
}
}
private void captureLoop() {
final int frameSamples = AudioDevices.FRAME_SIZE;
final byte[] buf = new byte[frameSamples * 2];
final float[] pcm = new float[frameSamples];
line.start();
while (running.get()) {
int read = 0;
try {
while (read < buf.length) {
int n = line.read(buf, read, buf.length - read);
if (n <= 0) break;
read += n;
}
} catch (Exception e) {
break;
}
if (read < buf.length) continue;
// 16-bit LE -> float, with input gain
for (int i = 0; i < frameSamples; i++) {
int lo = buf[2 * i] & 0xFF;
int hi = buf[2 * i + 1];
short s = (short) ((hi << 8) | lo);
float f = (float) (s / 32768.0 * inputGain);
if (f > 1f) f = 1f;
else if (f < -1f) f = -1f;
pcm[i] = f;
}
// Denoise / typing attenuation feed the level meter, VAD and encoder alike.
enhancer.process(pcm, frameSamples);
double sumSq = 0;
for (int i = 0; i < frameSamples; i++) {
sumSq += (double) pcm[i] * pcm[i];
}
double rms = Math.sqrt(sumSq / frameSamples);
double db = (rms <= 1e-9) ? -100.0 : 20.0 * Math.log10(rms);
Consumer<Double> ll = levelListener;
if (ll != null) ll.accept(db);
boolean open = decideGate(db, pcm);
setTransmitting(open);
if (open && !muted.get()) {
try {
byte[] packet;
synchronized (encoderLock) {
packet = encoder != null ? encoder.encode(pcm) : null;
}
if (packet != null && packet.length > 0) {
queue.offer(packet);
// Guard against unbounded growth if the network stalls.
while (queue.size() > 10) queue.poll();
}
} catch (Exception ignored) {
}
}
}
}
private boolean decideGate(double db, float[] pcm) {
if (muted.get()) {
hangover = 0;
return false;
}
switch (mode) {
case CONTINUOUS:
return true;
case PUSH_TO_TALK:
if (pttDown.get()) return true;
return vadOverPtt && voiceActivated(db, pcm);
case VOICE_ACTIVATION:
default:
return voiceActivated(db, pcm);
}
}
/**
* Applies the selected VAD mode (volume gate, speech probability, or both) with
* a deactivation-delay hangover so trailing syllables aren't clipped.
*/
private boolean voiceActivated(double db, float[] pcm) {
boolean detected;
switch (vadMode) {
case VOLUME_GATE:
detected = db >= thresholdDb;
break;
case AUTOMATIC:
detected = speechDetector.process(pcm) >= speechThreshold;
break;
case HYBRID:
default:
double probability = speechDetector.process(pcm);
detected = db >= thresholdDb && probability >= speechThreshold;
break;
}
if (detected) {
hangover = HANGOVER_FRAMES;
return true;
}
if (hangover > 0) {
hangover--;
return true;
}
return false;
}
private void setTransmitting(boolean t) {
transmitting.set(t);
if (t != lastTransmitting) {
lastTransmitting = t;
Consumer<Boolean> tl = talkListener;
if (tl != null) tl.accept(t);
}
}
// ---- ts3j Microphone contract ----
@Override
public boolean isMuted() {
return muted.get();
}
@Override
public boolean isReady() {
// Keep the ts3j sender "active" while we're transmitting or still have
// buffered packets. When both are false ts3j sends the terminating packet.
return transmitting.get() || !queue.isEmpty();
}
@Override
public CodecType getCodec() {
return codec;
}
@Override
public byte[] provide() {
byte[] p = queue.poll();
return p != null ? p : new byte[0];
}
}

View File

@@ -0,0 +1,191 @@
package com.ts3client.audio.desktop;
import com.github.manevolent.ts3j.protocol.packet.PacketBody0Voice;
import com.github.manevolent.ts3j.protocol.packet.PacketBody1VoiceWhisper;
import com.ts3client.audio.VoiceOutput;
import javax.sound.sampled.SourceDataLine;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.BiConsumer;
/**
* Java Sound {@link VoiceOutput}: decodes and plays incoming voice per speaker.
* Each client gets its own Opus decoder, playback line and worker thread, so
* simultaneous speakers are mixed by the OS and one slow decode never blocks
* another (or the network thread).
*/
public final class JavaSoundVoiceOutput implements VoiceOutput {
/** One speaker's decode + playback pipeline. */
private final class ClientStream {
final int clientId;
final OpusDecoder decoder;
final SourceDataLine line;
final ExecutorService worker;
volatile boolean talking;
volatile long lastPacketNanos;
ClientStream(int clientId) throws Exception {
this.clientId = clientId;
this.decoder = new OpusDecoder(AudioDevices.SAMPLE_RATE, AudioDevices.FRAME_SIZE, 1);
this.line = AudioDevices.openPlayback(outputDevice);
this.line.start();
this.worker = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "ts3j-play-" + clientId);
t.setDaemon(true);
return t;
});
}
void close() {
worker.shutdownNow();
try {
line.stop();
line.close();
} catch (Exception ignored) {
}
decoder.close();
}
}
private final Map<Integer, ClientStream> streams = new ConcurrentHashMap<>();
private final Set<Integer> mutedClients = ConcurrentHashMap.newKeySet();
private volatile String outputDevice;
private volatile double masterVolume = 1.0;
private volatile boolean deafened = false;
/** Notified (clientId, talking) on the EDT-agnostic worker thread when a speaker starts/stops. */
private volatile BiConsumer<Integer, Boolean> talkListener;
public JavaSoundVoiceOutput(String outputDevice) {
this.outputDevice = outputDevice;
}
public void setTalkListener(BiConsumer<Integer, Boolean> l) {
this.talkListener = l;
}
public void setMasterVolume(double v) {
this.masterVolume = Math.max(0, Math.min(2.0, v));
}
public void setDeafened(boolean d) {
this.deafened = d;
if (d) {
// Stop everyone talking immediately.
for (ClientStream s : streams.values()) {
markTalking(s, false);
}
}
}
public boolean isDeafened() {
return deafened;
}
public void setClientMuted(int clientId, boolean muted) {
if (muted) mutedClients.add(clientId);
else mutedClients.remove(clientId);
}
public boolean isClientMuted(int clientId) {
return mutedClients.contains(clientId);
}
public void setOutputDevice(String device) {
this.outputDevice = device;
}
/** Entry point wired into {@code client.setVoiceHandler(...)}. */
public void handleVoice(PacketBody0Voice voice) {
route(voice.getClientId(), voice.getCodecData());
}
/** Entry point wired into {@code client.setWhisperHandler(...)}. */
public void handleWhisper(PacketBody1VoiceWhisper whisper) {
route(whisper.getClientId(), whisper.getCodecData());
}
private void route(int clientId, byte[] data) {
if (deafened) return;
if (mutedClients.contains(clientId)) return;
ClientStream stream = streams.get(clientId);
if (stream == null) {
try {
stream = new ClientStream(clientId);
ClientStream existing = streams.putIfAbsent(clientId, stream);
if (existing != null) {
stream.close();
stream = existing;
}
} catch (Exception e) {
return; // couldn't open a line; drop
}
}
final ClientStream target = stream;
target.lastPacketNanos = System.nanoTime();
if (data == null || data.length == 0) {
// End of a talk burst: flush and reset the decoder, mark silent.
target.worker.submit(() -> {
try {
target.line.drain();
} catch (Exception ignored) {
}
target.decoder.reset();
markTalking(target, false);
});
return;
}
markTalking(target, true);
target.worker.submit(() -> decodeAndPlay(target, data));
}
private void decodeAndPlay(ClientStream stream, byte[] data) {
try {
float[] pcm = new float[AudioDevices.FRAME_SIZE];
int samples = stream.decoder.decode(data, pcm);
double vol = masterVolume;
byte[] out = new byte[samples * 2];
for (int i = 0; i < samples; i++) {
double v = pcm[i] * vol;
if (v > 1.0) v = 1.0;
else if (v < -1.0) v = -1.0;
short s = (short) Math.round(v * 32767.0);
out[2 * i] = (byte) (s & 0xFF);
out[2 * i + 1] = (byte) ((s >> 8) & 0xFF);
}
stream.line.write(out, 0, out.length);
} catch (Exception ignored) {
}
}
private void markTalking(ClientStream stream, boolean talking) {
if (stream.talking == talking) return;
stream.talking = talking;
BiConsumer<Integer, Boolean> l = talkListener;
if (l != null) l.accept(stream.clientId, talking);
}
/** Drop a speaker's pipeline entirely (e.g. they left the server). */
public void removeClient(int clientId) {
ClientStream s = streams.remove(clientId);
if (s != null) s.close();
}
public void shutdown() {
for (ClientStream s : streams.values()) {
s.close();
}
streams.clear();
}
}

View File

@@ -0,0 +1,61 @@
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;
/**
* Minimal JNA binding to the native Opus codec library (libopus).
*
* <p>We bind directly to the system-installed {@code libopus.so}/{@code opus.dll}
* rather than relying on a bundled wrapper. Opus always operates internally at
* 48&nbsp;kHz which matches what the TeamSpeak 3 protocol uses on the wire.
*/
public interface Opus extends Library {
Opus INSTANCE = Native.load("opus", Opus.class);
// ---- application types (opus_defines.h) ----
int OPUS_APPLICATION_VOIP = 2048;
int OPUS_APPLICATION_AUDIO = 2049;
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;
// ---- signal hints ----
int OPUS_AUTO = -1000;
int OPUS_SIGNAL_VOICE = 3001;
int OPUS_SIGNAL_MUSIC = 3002;
// ---- encoder ----
PointerByReference opus_encoder_create(int fs, int channels, int application, IntBuffer error);
int opus_encode_float(PointerByReference st, float[] pcm, int frameSize, byte[] data, int maxDataBytes);
int opus_encoder_ctl(PointerByReference st, int request, Object... args);
void opus_encoder_destroy(PointerByReference st);
// ---- decoder ----
PointerByReference opus_decoder_create(int fs, int channels, IntBuffer error);
int opus_decode_float(PointerByReference st, byte[] data, int len, float[] pcm, int frameSize, int decodeFec);
int opus_decoder_ctl(PointerByReference st, int request, Object... args);
void opus_decoder_destroy(PointerByReference st);
// ---- misc ----
String opus_get_version_string();
String opus_strerror(int error);
}

View File

@@ -0,0 +1,72 @@
package com.ts3client.audio.desktop;
import com.sun.jna.ptr.PointerByReference;
import java.nio.IntBuffer;
/**
* Thin wrapper around a native Opus decoder.
*
* <p>Created as mono at 48&nbsp;kHz; Opus transparently down-mixes stereo streams
* (e.g. music-bot audio) to the requested channel count, so a single mono decoder
* copes with both {@code OPUS_VOICE} and {@code OPUS_MUSIC} payloads.
*/
public final class OpusDecoder implements AutoCloseable {
private final PointerByReference handle;
private final int frameSize;
private final int channels;
private boolean closed;
public OpusDecoder(int sampleRate, int frameSize, int channels) {
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)));
}
}
/**
* Decodes an Opus packet to interleaved float PCM.
*
* @param packet the encoded packet, or {@code null} to request packet-loss
* concealment (PLC) for a missing frame
* @param out output buffer, at least {@code frameSize * channels} long
* @return number of samples decoded per channel
*/
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));
}
return samples;
}
public void reset() {
if (closed) return;
Opus.INSTANCE.opus_decoder_ctl(handle, Opus.OPUS_RESET_STATE);
}
public int getChannels() {
return channels;
}
@Override
public void close() {
if (closed) return;
closed = true;
Opus.INSTANCE.opus_decoder_destroy(handle);
}
}

View File

@@ -0,0 +1,101 @@
package com.ts3client.audio.desktop;
import com.sun.jna.ptr.PointerByReference;
import java.nio.IntBuffer;
/**
* 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.
*/
public final class OpusEncoder implements AutoCloseable {
private final PointerByReference handle;
private final int frameSize;
private final int channels;
private final byte[] out = new byte[4096];
private final Object lock = new Object();
private boolean closed;
public OpusEncoder(int sampleRate, int frameSize, int channels, int application) {
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)));
}
}
public void setBitrate(int bitsPerSecond) {
ctl(Opus.OPUS_SET_BITRATE_REQUEST, bitsPerSecond);
}
public void setComplexity(int complexity) {
ctl(Opus.OPUS_SET_COMPLEXITY_REQUEST, Math.max(0, Math.min(10, complexity)));
}
public void setVbr(boolean vbr) {
ctl(Opus.OPUS_SET_VBR_REQUEST, vbr ? 1 : 0);
}
public void setInbandFec(boolean fec) {
ctl(Opus.OPUS_SET_INBAND_FEC_REQUEST, fec ? 1 : 0);
}
public void setExpectedPacketLoss(int percent) {
ctl(Opus.OPUS_SET_PACKET_LOSS_PERC_REQUEST, Math.max(0, Math.min(100, percent)));
}
public void setSignal(int signal) {
ctl(Opus.OPUS_SET_SIGNAL_REQUEST, signal);
}
private void ctl(int request, int value) {
synchronized (lock) {
if (closed) return;
int r = Opus.INSTANCE.opus_encoder_ctl(handle, request, value);
if (r < 0) {
throw new IllegalStateException("opus_encoder_ctl(" + request + ") failed: "
+ Opus.INSTANCE.opus_strerror(r));
}
}
}
/**
* Encodes one frame of interleaved float PCM ({@code frameSize * channels} samples)
* into an Opus packet.
*
* @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);
}
synchronized (lock) {
if (closed) throw new IllegalStateException("encoder closed");
int len = Opus.INSTANCE.opus_encode_float(handle, pcm, frameSize, out, out.length);
if (len < 0) {
throw new IllegalStateException("opus_encode_float failed: "
+ Opus.INSTANCE.opus_strerror(len));
}
byte[] packet = new byte[len];
System.arraycopy(out, 0, packet, 0, len);
return packet;
}
}
@Override
public void close() {
synchronized (lock) {
if (closed) return;
closed = true;
Opus.INSTANCE.opus_encoder_destroy(handle);
}
}
}