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:
31
ts3-client/desktop/pom.xml
Normal file
31
ts3-client/desktop/pom.xml
Normal 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>
|
||||
@@ -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 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
|
||||
}
|
||||
}
|
||||
@@ -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)";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 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];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 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);
|
||||
}
|
||||
@@ -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 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);
|
||||
}
|
||||
}
|
||||
@@ -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 kHz. Frames are 20 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user