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:
@@ -0,0 +1,17 @@
|
||||
package com.ts3client.audio;
|
||||
|
||||
import com.ts3client.config.Settings;
|
||||
|
||||
/**
|
||||
* Factory for a platform's voice capture and playback. Injected into the
|
||||
* connection layer so the core stays independent of any concrete audio stack.
|
||||
*/
|
||||
public interface AudioBackend {
|
||||
|
||||
VoiceInput createInput(Settings settings);
|
||||
|
||||
VoiceOutput createOutput(Settings settings);
|
||||
|
||||
/** Human-readable codec/backend description, e.g. for an "about" line. */
|
||||
String description();
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package com.ts3client.audio;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* Microphone pre-processing chain applied before voice activation and Opus encoding,
|
||||
* mirroring the capture-side denoise/typing filters of the TeamSpeak 3 client.
|
||||
*
|
||||
* <p>The stages run in the same order as the TeamSpeak client's WebRTC capture chain:
|
||||
* a {@link HighPassFilter} (always-on rumble/DC removal), then a streaming short-time
|
||||
* Fourier transform (square-root Hann window, 50% overlap-add) carrying a
|
||||
* {@link NoiseSuppressor} ("Remove background noise") and a {@link TypingAttenuator}
|
||||
* ("Typing attenuation") — sharing one FFT/IFFT per hop — and finally an
|
||||
* {@link AutomaticGainControl} ("AGC"). Echo cancellation (WebRTC AEC3) is omitted as
|
||||
* it requires the loudspeaker reference signal.
|
||||
*
|
||||
* <p>Input frames of any length are decoupled from the STFT hop by internal ring
|
||||
* buffers; when noise/typing suppression is active the output is delayed by one hop
|
||||
* (~5 ms). The high-pass filter and AGC are zero-latency. When every stage is
|
||||
* disabled the chain is fully bypassed and audio passes through untouched.
|
||||
*
|
||||
* <p>Pure DSP with no platform dependencies, so any frontend/backend can reuse it.
|
||||
* Not thread-safe: drive it from a single capture thread; the enable/level setters
|
||||
* are cheap volatiles safe to call from the UI thread.
|
||||
*/
|
||||
public final class AudioEnhancer {
|
||||
|
||||
private static final int FFT_SIZE = 512; // power of two -> 10.7 ms @ 48 kHz
|
||||
private static final int HOP = FFT_SIZE / 2; // 50% overlap
|
||||
private static final int BINS = FFT_SIZE / 2 + 1;
|
||||
|
||||
private final double[] window = new double[FFT_SIZE];
|
||||
private final double[] re = new double[FFT_SIZE];
|
||||
private final double[] im = new double[FFT_SIZE];
|
||||
private final double[] power = new double[BINS];
|
||||
private final double[] gain = new double[BINS];
|
||||
|
||||
private final double[] frame = new double[FFT_SIZE]; // sliding analysis frame
|
||||
private final double[] ola = new double[FFT_SIZE]; // overlap-add accumulator
|
||||
|
||||
private final FloatRing input = new FloatRing(FFT_SIZE * 4);
|
||||
private final FloatRing output = new FloatRing(FFT_SIZE * 4);
|
||||
private final float[] hopIn = new float[HOP];
|
||||
|
||||
private final NoiseSuppressor noiseSuppressor = new NoiseSuppressor(BINS);
|
||||
private final TypingAttenuator typingAttenuator;
|
||||
private final HighPassFilter highPass;
|
||||
private final AutomaticGainControl agc;
|
||||
|
||||
private volatile boolean noiseEnabled;
|
||||
private volatile boolean typingEnabled;
|
||||
private volatile boolean agcEnabled;
|
||||
private boolean active; // any stage on: HPF + AGC state is live
|
||||
private boolean stftRunning; // noise/typing on: STFT rings are live
|
||||
|
||||
public AudioEnhancer(int sampleRate) {
|
||||
for (int i = 0; i < FFT_SIZE; i++) {
|
||||
// sqrt(Hann): analysis*synthesis = Hann, which is COLA at 50% overlap.
|
||||
window[i] = Math.sqrt(0.5 * (1 - Math.cos(2 * Math.PI * i / FFT_SIZE)));
|
||||
}
|
||||
this.typingAttenuator = new TypingAttenuator(BINS, sampleRate, FFT_SIZE);
|
||||
this.highPass = new HighPassFilter(sampleRate);
|
||||
this.agc = new AutomaticGainControl(sampleRate);
|
||||
}
|
||||
|
||||
public void setNoiseSuppression(boolean enabled) {
|
||||
this.noiseEnabled = enabled;
|
||||
}
|
||||
|
||||
public void setDenoiserLevel(double level) {
|
||||
noiseSuppressor.setLevel(level);
|
||||
}
|
||||
|
||||
public void setTypingAttenuation(boolean enabled) {
|
||||
this.typingEnabled = enabled;
|
||||
}
|
||||
|
||||
public void setAgc(boolean enabled) {
|
||||
this.agcEnabled = enabled;
|
||||
}
|
||||
|
||||
/** Clears all filter state; call when (re)starting capture. */
|
||||
public void reset() {
|
||||
resetStft();
|
||||
highPass.reset();
|
||||
agc.reset();
|
||||
active = false;
|
||||
stftRunning = false;
|
||||
}
|
||||
|
||||
private void resetStft() {
|
||||
input.clear();
|
||||
output.clear();
|
||||
Arrays.fill(frame, 0);
|
||||
Arrays.fill(ola, 0);
|
||||
noiseSuppressor.reset();
|
||||
typingAttenuator.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhances one frame of mono PCM in place. {@code buf[0..len)} is overwritten with
|
||||
* the processed (one-hop-delayed when noise/typing suppression is on) signal.
|
||||
* Returns immediately if every stage is disabled.
|
||||
*/
|
||||
public void process(float[] buf, int len) {
|
||||
boolean stft = noiseEnabled || typingEnabled;
|
||||
boolean anyStage = stft || agcEnabled;
|
||||
if (!anyStage) {
|
||||
if (active) reset(); // drop stale filter/delay state on full disable
|
||||
return;
|
||||
}
|
||||
if (!active) {
|
||||
reset();
|
||||
active = true;
|
||||
}
|
||||
|
||||
// 1) High-pass filter (always-on part of the active chain).
|
||||
highPass.process(buf, len);
|
||||
|
||||
// 2) STFT noise + typing suppression (only when either is enabled).
|
||||
if (stft) {
|
||||
if (!stftRunning) {
|
||||
resetStft();
|
||||
stftRunning = true;
|
||||
}
|
||||
runStft(buf, len);
|
||||
} else if (stftRunning) {
|
||||
stftRunning = false;
|
||||
}
|
||||
|
||||
// 3) Automatic gain control (last, on the cleaned signal).
|
||||
if (agcEnabled) {
|
||||
agc.process(buf, len);
|
||||
}
|
||||
}
|
||||
|
||||
private void runStft(float[] buf, int len) {
|
||||
input.write(buf, len);
|
||||
while (input.available() >= HOP) {
|
||||
System.arraycopy(frame, HOP, frame, 0, FFT_SIZE - HOP);
|
||||
input.read(hopIn, HOP);
|
||||
for (int i = 0; i < HOP; i++) {
|
||||
frame[FFT_SIZE - HOP + i] = hopIn[i];
|
||||
}
|
||||
processBlock();
|
||||
}
|
||||
|
||||
// During the initial one-hop priming the output ring is short; pad with zeros.
|
||||
int ready = output.available();
|
||||
if (ready < len) {
|
||||
for (int i = 0; i < len - ready; i++) buf[i] = 0f;
|
||||
output.read(buf, len - ready, ready);
|
||||
} else {
|
||||
output.read(buf, 0, len);
|
||||
}
|
||||
}
|
||||
|
||||
private void processBlock() {
|
||||
for (int i = 0; i < FFT_SIZE; i++) {
|
||||
re[i] = frame[i] * window[i];
|
||||
im[i] = 0;
|
||||
}
|
||||
Fft.forward(re, im);
|
||||
|
||||
for (int k = 0; k < BINS; k++) {
|
||||
power[k] = re[k] * re[k] + im[k] * im[k];
|
||||
gain[k] = 1.0;
|
||||
}
|
||||
if (noiseEnabled) noiseSuppressor.apply(power, gain);
|
||||
if (typingEnabled) typingAttenuator.apply(power, gain);
|
||||
|
||||
// Apply the real-valued gain to each bin and its conjugate mirror.
|
||||
for (int k = 0; k < BINS; k++) {
|
||||
double g = gain[k];
|
||||
re[k] *= g;
|
||||
im[k] *= g;
|
||||
if (k > 0 && k < FFT_SIZE - k) {
|
||||
int m = FFT_SIZE - k;
|
||||
re[m] *= g;
|
||||
im[m] *= g;
|
||||
}
|
||||
}
|
||||
Fft.inverse(re, im);
|
||||
|
||||
for (int i = 0; i < FFT_SIZE; i++) {
|
||||
ola[i] += re[i] * window[i];
|
||||
}
|
||||
output.write(ola, HOP);
|
||||
System.arraycopy(ola, HOP, ola, 0, FFT_SIZE - HOP);
|
||||
Arrays.fill(ola, FFT_SIZE - HOP, FFT_SIZE, 0);
|
||||
}
|
||||
|
||||
/** Minimal single-producer/single-consumer float ring buffer. */
|
||||
private static final class FloatRing {
|
||||
private final float[] buf;
|
||||
private int head, tail, size;
|
||||
|
||||
FloatRing(int capacity) {
|
||||
this.buf = new float[capacity];
|
||||
}
|
||||
|
||||
int available() {
|
||||
return size;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
head = tail = size = 0;
|
||||
}
|
||||
|
||||
void write(float[] src, int len) {
|
||||
for (int i = 0; i < len; i++) {
|
||||
buf[tail] = src[i];
|
||||
tail = (tail + 1) % buf.length;
|
||||
}
|
||||
size += len;
|
||||
}
|
||||
|
||||
void write(double[] src, int len) {
|
||||
for (int i = 0; i < len; i++) {
|
||||
buf[tail] = (float) src[i];
|
||||
tail = (tail + 1) % buf.length;
|
||||
}
|
||||
size += len;
|
||||
}
|
||||
|
||||
void read(float[] dst, int len) {
|
||||
read(dst, 0, len);
|
||||
}
|
||||
|
||||
void read(float[] dst, int offset, int len) {
|
||||
for (int i = 0; i < len; i++) {
|
||||
dst[offset + i] = buf[head];
|
||||
head = (head + 1) % buf.length;
|
||||
}
|
||||
size -= len;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.ts3client.audio;
|
||||
|
||||
/**
|
||||
* Automatic gain control — WebRTC APM's {@code gain_controller} (AGC2 adaptive
|
||||
* digital) / Speex {@code AGC} stage. It normalises voice loudness toward a target
|
||||
* level so quiet microphones are boosted and loud ones tamed, keeping perceived volume
|
||||
* consistent across speakers.
|
||||
*
|
||||
* <p>Placed last in the capture chain (after noise suppression), it tracks the frame
|
||||
* level and moves an applied gain toward {@code target / level}: it attenuates quickly
|
||||
* to head off clipping and boosts slowly to avoid pumping. A noise gate freezes the
|
||||
* gain while the input is near silence, so background noise between words is never
|
||||
* amplified; the per-sample gain ramp avoids zipper artefacts and a final clamp guards
|
||||
* against overshoot.
|
||||
*/
|
||||
final class AutomaticGainControl {
|
||||
|
||||
private static final double TARGET_RMS = 0.12; // ~ -18.4 dBFS
|
||||
private static final double MAX_GAIN = dbToGain(30); // up to +30 dB boost
|
||||
private static final double MIN_GAIN = dbToGain(-20); // down to -20 dB
|
||||
private static final double NOISE_GATE_RMS = dbToGain(-55); // freeze below this level
|
||||
|
||||
private final double attackCoeff; // gain decreasing (signal too loud): fast
|
||||
private final double releaseCoeff; // gain increasing (too quiet): slow
|
||||
|
||||
private double gain = 1.0;
|
||||
|
||||
AutomaticGainControl(int sampleRate) {
|
||||
this.attackCoeff = 1 - Math.exp(-1.0 / (0.005 * sampleRate)); // ~5 ms
|
||||
this.releaseCoeff = 1 - Math.exp(-1.0 / (0.300 * sampleRate)); // ~300 ms
|
||||
}
|
||||
|
||||
void reset() {
|
||||
gain = 1.0;
|
||||
}
|
||||
|
||||
/** Applies gain normalisation to one mono frame in place. */
|
||||
void process(float[] buf, int len) {
|
||||
double sumSq = 0;
|
||||
for (int i = 0; i < len; i++) {
|
||||
sumSq += (double) buf[i] * buf[i];
|
||||
}
|
||||
double rms = Math.sqrt(sumSq / len);
|
||||
|
||||
double desired = gain;
|
||||
if (rms >= NOISE_GATE_RMS) {
|
||||
desired = TARGET_RMS / rms;
|
||||
if (desired > MAX_GAIN) desired = MAX_GAIN;
|
||||
else if (desired < MIN_GAIN) desired = MIN_GAIN;
|
||||
}
|
||||
// Boost slowly, attenuate quickly.
|
||||
double coeff = desired < gain ? attackCoeff : releaseCoeff;
|
||||
|
||||
for (int i = 0; i < len; i++) {
|
||||
gain += (desired - gain) * coeff;
|
||||
double y = buf[i] * gain;
|
||||
if (y > 1.0) y = 1.0;
|
||||
else if (y < -1.0) y = -1.0;
|
||||
buf[i] = (float) y;
|
||||
}
|
||||
}
|
||||
|
||||
private static double dbToGain(double db) {
|
||||
return Math.pow(10.0, db / 20.0);
|
||||
}
|
||||
}
|
||||
68
ts3-client/core/src/main/java/com/ts3client/audio/Fft.java
Normal file
68
ts3-client/core/src/main/java/com/ts3client/audio/Fft.java
Normal file
@@ -0,0 +1,68 @@
|
||||
package com.ts3client.audio;
|
||||
|
||||
/**
|
||||
* In-place iterative radix-2 Cooley–Tukey FFT shared by the voice DSP stages.
|
||||
* All arrays must have a power-of-two length. Pure math, no platform dependencies.
|
||||
*/
|
||||
final class Fft {
|
||||
|
||||
private Fft() {
|
||||
}
|
||||
|
||||
/** Forward transform (unnormalised). */
|
||||
static void forward(double[] re, double[] im) {
|
||||
transform(re, im, false);
|
||||
}
|
||||
|
||||
/** Inverse transform, normalised by {@code 1/n} so it inverts {@link #forward}. */
|
||||
static void inverse(double[] re, double[] im) {
|
||||
transform(re, im, true);
|
||||
int n = re.length;
|
||||
double scale = 1.0 / n;
|
||||
for (int i = 0; i < n; i++) {
|
||||
re[i] *= scale;
|
||||
im[i] *= scale;
|
||||
}
|
||||
}
|
||||
|
||||
private static void transform(double[] re, double[] im, boolean inverse) {
|
||||
int n = re.length;
|
||||
for (int i = 1, j = 0; i < n; i++) {
|
||||
int bit = n >> 1;
|
||||
for (; (j & bit) != 0; bit >>= 1) {
|
||||
j ^= bit;
|
||||
}
|
||||
j ^= bit;
|
||||
if (i < j) {
|
||||
double tr = re[i];
|
||||
re[i] = re[j];
|
||||
re[j] = tr;
|
||||
double ti = im[i];
|
||||
im[i] = im[j];
|
||||
im[j] = ti;
|
||||
}
|
||||
}
|
||||
double sign = inverse ? 2 * Math.PI : -2 * Math.PI;
|
||||
for (int len = 2; len <= n; len <<= 1) {
|
||||
double ang = sign / len;
|
||||
double wr = Math.cos(ang);
|
||||
double wi = Math.sin(ang);
|
||||
for (int i = 0; i < n; i += len) {
|
||||
double curR = 1, curI = 0;
|
||||
for (int k = 0; k < len / 2; k++) {
|
||||
int a = i + k;
|
||||
int b = i + k + len / 2;
|
||||
double vr = re[b] * curR - im[b] * curI;
|
||||
double vi = re[b] * curI + im[b] * curR;
|
||||
re[b] = re[a] - vr;
|
||||
im[b] = im[a] - vi;
|
||||
re[a] += vr;
|
||||
im[a] += vi;
|
||||
double nr = curR * wr - curI * wi;
|
||||
curI = curR * wi + curI * wr;
|
||||
curR = nr;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.ts3client.audio;
|
||||
|
||||
/**
|
||||
* Second-order Butterworth high-pass filter (RBJ biquad, transposed direct form II).
|
||||
* Mirrors WebRTC APM's {@code high_pass_filter} stage — an always-on part of the
|
||||
* capture chain that removes DC offset, mains hum and low-frequency rumble below the
|
||||
* speech band before noise suppression sees the signal.
|
||||
*/
|
||||
final class HighPassFilter {
|
||||
|
||||
private static final double CUTOFF_HZ = 80.0; // WebRTC APM high-pass cutoff
|
||||
|
||||
private final double b0, b1, b2, a1, a2;
|
||||
private double z1, z2;
|
||||
|
||||
HighPassFilter(int sampleRate) {
|
||||
double w0 = 2 * Math.PI * CUTOFF_HZ / sampleRate;
|
||||
double cos = Math.cos(w0);
|
||||
double alpha = Math.sin(w0) / Math.sqrt(2.0); // Q = 1/sqrt(2) (Butterworth)
|
||||
double a0 = 1 + alpha;
|
||||
this.b0 = (1 + cos) / 2 / a0;
|
||||
this.b1 = -(1 + cos) / a0;
|
||||
this.b2 = (1 + cos) / 2 / a0;
|
||||
this.a1 = -2 * cos / a0;
|
||||
this.a2 = (1 - alpha) / a0;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
z1 = 0;
|
||||
z2 = 0;
|
||||
}
|
||||
|
||||
/** Filters one mono frame in place. */
|
||||
void process(float[] buf, int len) {
|
||||
for (int i = 0; i < len; i++) {
|
||||
double x = buf[i];
|
||||
double y = b0 * x + z1;
|
||||
z1 = b1 * x - a1 * y + z2;
|
||||
z2 = b2 * x - a2 * y;
|
||||
buf[i] = (float) y;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.ts3client.audio;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* Single-channel spectral noise suppressor — the "Remove background noise"
|
||||
* (denoise) stage. The TeamSpeak 3 client filters steady background noise with
|
||||
* WebRTC's {@code noise_suppression} module (and a Speex denoiser fallback); this is
|
||||
* a self-contained equivalent that operates on the STFT bins produced by
|
||||
* {@link AudioEnhancer}.
|
||||
*
|
||||
* <p>The noise floor is tracked per bin by continuous minimum statistics (Doblinger's
|
||||
* recursive minimum tracker): the estimate follows the valleys of the smoothed power
|
||||
* spectrum, so modulated speech — which dips between syllables — is
|
||||
* preserved while only near-stationary background energy is learned as noise. From
|
||||
* that floor a Wiener gain is formed with a decision-directed a priori SNR
|
||||
* (Ephraim–Malah smoothing, which keeps musical noise low). A configurable
|
||||
* aggressiveness ({@code denoiser_level}, 0–1) sets both the over-subtraction
|
||||
* factor and the gain floor, i.e. how deeply steady noise is cut.
|
||||
*/
|
||||
final class NoiseSuppressor {
|
||||
|
||||
/** Power-spectrum smoothing feeding the minimum tracker. */
|
||||
private static final double POWER_SMOOTH = 0.7;
|
||||
/** Doblinger minimum-tracker constants. */
|
||||
private static final double MIN_GAMMA = 0.998;
|
||||
private static final double MIN_BETA = 0.96;
|
||||
/** Over-estimation applied to the tracked minimum to get the noise power. */
|
||||
private static final double NOISE_OVEREST = 1.5;
|
||||
/** Decision-directed smoothing of the a priori SNR (higher = less musical noise). */
|
||||
private static final double DD_ALPHA = 0.98;
|
||||
/** Floor on the a priori SNR (~ -25 dB) to bound the deepest Wiener gain. */
|
||||
private static final double XI_MIN = 0.003;
|
||||
|
||||
private final int bins;
|
||||
private final double[] smoothed;
|
||||
private final double[] prevSmoothed;
|
||||
private final double[] minTrack;
|
||||
private final double[] priorClean; // previous enhanced power, for the DD estimate
|
||||
|
||||
private double overSubtraction = 1.5;
|
||||
private double gainFloor = dbToGain(-18);
|
||||
private boolean initialised;
|
||||
|
||||
NoiseSuppressor(int bins) {
|
||||
this.bins = bins;
|
||||
this.smoothed = new double[bins];
|
||||
this.prevSmoothed = new double[bins];
|
||||
this.minTrack = new double[bins];
|
||||
this.priorClean = new double[bins];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets aggressiveness in [0,1]. 0 is a light touch (~6 dB max cut), 1 is
|
||||
* heavy (~30 dB) with stronger over-subtraction.
|
||||
*/
|
||||
void setLevel(double level) {
|
||||
double l = Math.max(0, Math.min(1, level));
|
||||
this.gainFloor = dbToGain(-(6 + 24 * l));
|
||||
this.overSubtraction = 1.0 + 1.5 * l;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
initialised = false;
|
||||
Arrays.fill(smoothed, 0);
|
||||
Arrays.fill(prevSmoothed, 0);
|
||||
Arrays.fill(minTrack, 0);
|
||||
Arrays.fill(priorClean, 0);
|
||||
}
|
||||
|
||||
/** Multiplies the running per-bin gain by this stage's Wiener gain. */
|
||||
void apply(double[] power, double[] gain) {
|
||||
if (!initialised) {
|
||||
for (int k = 0; k < bins; k++) {
|
||||
smoothed[k] = prevSmoothed[k] = minTrack[k] = power[k];
|
||||
priorClean[k] = power[k];
|
||||
}
|
||||
initialised = true;
|
||||
}
|
||||
for (int k = 0; k < bins; k++) {
|
||||
double p = power[k] + 1e-12;
|
||||
|
||||
double s = POWER_SMOOTH * smoothed[k] + (1 - POWER_SMOOTH) * p;
|
||||
// Doblinger continuous minimum tracking of the smoothed power.
|
||||
double mt;
|
||||
if (minTrack[k] < s) {
|
||||
mt = MIN_GAMMA * minTrack[k]
|
||||
+ ((1 - MIN_GAMMA) / (1 - MIN_BETA)) * (s - MIN_BETA * smoothed[k]);
|
||||
} else {
|
||||
mt = s;
|
||||
}
|
||||
minTrack[k] = mt;
|
||||
smoothed[k] = s;
|
||||
double noiseK = NOISE_OVEREST * mt + 1e-12;
|
||||
|
||||
double gamma = p / (noiseK * overSubtraction); // a posteriori SNR
|
||||
double xi = DD_ALPHA * (priorClean[k] / noiseK)
|
||||
+ (1 - DD_ALPHA) * Math.max(gamma - 1, 0); // a priori SNR
|
||||
if (xi < XI_MIN) xi = XI_MIN;
|
||||
|
||||
double g = xi / (1 + xi); // Wiener gain
|
||||
if (g < gainFloor) g = gainFloor;
|
||||
|
||||
priorClean[k] = g * g * p;
|
||||
gain[k] *= g;
|
||||
}
|
||||
}
|
||||
|
||||
private static double dbToGain(double db) {
|
||||
return Math.pow(10.0, db / 20.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.ts3client.audio;
|
||||
|
||||
import com.ts3client.config.Settings;
|
||||
|
||||
/**
|
||||
* Immutable set of Opus encoder settings that a {@link VoiceInput} can apply,
|
||||
* including while capturing. Frontend-agnostic: carries no native handles.
|
||||
*/
|
||||
public final class OpusParameters {
|
||||
|
||||
public static OpusParameters from(Settings s) {
|
||||
return new OpusParameters(s.bitrate, s.complexity, s.vbr, s.fec, s.packetLoss, s.music);
|
||||
}
|
||||
|
||||
/** Target bitrate in bits per second. */
|
||||
public final int bitrate;
|
||||
/** Encoder complexity, 0 (fast) .. 10 (best). */
|
||||
public final int complexity;
|
||||
/** Variable bitrate. */
|
||||
public final boolean vbr;
|
||||
/** In-band forward error correction. */
|
||||
public final boolean fec;
|
||||
/** Expected packet loss, 0..100 percent (drives FEC redundancy). */
|
||||
public final int expectedPacketLoss;
|
||||
/** {@code true} to encode as music (OPUS_MUSIC, stereo-friendly); {@code false} for voice. */
|
||||
public final boolean music;
|
||||
|
||||
public OpusParameters(int bitrate, int complexity, boolean vbr, boolean fec,
|
||||
int expectedPacketLoss, boolean music) {
|
||||
this.bitrate = bitrate;
|
||||
this.complexity = Math.max(0, Math.min(10, complexity));
|
||||
this.vbr = vbr;
|
||||
this.fec = fec;
|
||||
this.expectedPacketLoss = Math.max(0, Math.min(100, expectedPacketLoss));
|
||||
this.music = music;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.ts3client.audio;
|
||||
|
||||
/**
|
||||
* Lightweight speech-presence detector used by the "Automatic" and "Hybrid" voice
|
||||
* activation modes (the TeamSpeak 3 client uses a WebRTC GMM detector for the same
|
||||
* purpose). It classifies each 20 ms frame from three features — short-term
|
||||
* energy, spectral flatness and dominant frequency — against an adaptive noise
|
||||
* floor, following Moattar & Homayounpour's real-time VAD.
|
||||
*
|
||||
* <p>Pure DSP with no platform dependencies, so any frontend/backend can reuse it.
|
||||
* A frame votes for speech when at least two of the three features exceed the
|
||||
* baseline; the smoothed vote fraction is exposed as a [0,1] probability.
|
||||
*/
|
||||
public final class SpeechDetector {
|
||||
|
||||
private static final int FFT_SIZE = 1024;
|
||||
|
||||
// Primary thresholds from the reference algorithm (16-bit sample scale).
|
||||
private static final double ENERGY_PRIM = 40.0;
|
||||
private static final double DOMINANT_FREQ_PRIM = 185.0;
|
||||
private static final double FLATNESS_PRIM = 5.0;
|
||||
|
||||
private final int sampleRate;
|
||||
private final double[] re = new double[FFT_SIZE];
|
||||
private final double[] im = new double[FFT_SIZE];
|
||||
|
||||
private double minEnergy;
|
||||
private double minDominantFreq;
|
||||
private double minFlatness;
|
||||
private long silenceFrames;
|
||||
private long frameCount;
|
||||
private double probability;
|
||||
|
||||
public SpeechDetector(int sampleRate) {
|
||||
this.sampleRate = sampleRate;
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
frameCount = 0;
|
||||
silenceFrames = 0;
|
||||
probability = 0;
|
||||
}
|
||||
|
||||
public double getProbability() {
|
||||
return probability;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes one frame of mono PCM in [-1,1] and returns the smoothed speech
|
||||
* probability in [0,1].
|
||||
*/
|
||||
public double process(float[] frame) {
|
||||
double energy = 0;
|
||||
int n = Math.min(frame.length, FFT_SIZE);
|
||||
for (int i = 0; i < n; i++) {
|
||||
double s = frame[i] * 32768.0; // emulate 16-bit scale
|
||||
energy += s * s;
|
||||
re[i] = frame[i];
|
||||
im[i] = 0;
|
||||
}
|
||||
for (int i = n; i < FFT_SIZE; i++) {
|
||||
re[i] = 0;
|
||||
im[i] = 0;
|
||||
}
|
||||
|
||||
Fft.forward(re, im);
|
||||
|
||||
int half = FFT_SIZE / 2;
|
||||
double geoLogSum = 0;
|
||||
double arithSum = 0;
|
||||
double maxMag = 0;
|
||||
int maxBin = 0;
|
||||
for (int k = 1; k < half; k++) {
|
||||
double mag = Math.sqrt(re[k] * re[k] + im[k] * im[k]) + 1e-12;
|
||||
geoLogSum += Math.log(mag);
|
||||
arithSum += mag;
|
||||
if (mag > maxMag) {
|
||||
maxMag = mag;
|
||||
maxBin = k;
|
||||
}
|
||||
}
|
||||
int bins = half - 1;
|
||||
double geoMean = Math.exp(geoLogSum / bins);
|
||||
double arithMean = arithSum / bins;
|
||||
double flatness = -10.0 * Math.log10(geoMean / arithMean); // high = tonal (speech)
|
||||
double dominantFreq = (double) maxBin * sampleRate / FFT_SIZE;
|
||||
|
||||
if (frameCount == 0) {
|
||||
minEnergy = Math.max(energy, 1.0);
|
||||
minDominantFreq = dominantFreq;
|
||||
minFlatness = flatness;
|
||||
}
|
||||
|
||||
double energyThresh = ENERGY_PRIM * Math.log10(Math.max(minEnergy, 1.0));
|
||||
int votes = 0;
|
||||
if (energy - minEnergy >= energyThresh) votes++;
|
||||
if (dominantFreq - minDominantFreq >= DOMINANT_FREQ_PRIM) votes++;
|
||||
if (flatness - minFlatness >= FLATNESS_PRIM) votes++;
|
||||
|
||||
boolean speech = votes >= 2;
|
||||
if (!speech) {
|
||||
// Adapt the noise floor towards the current (silent) energy.
|
||||
silenceFrames++;
|
||||
minEnergy = ((silenceFrames * minEnergy) + energy) / (silenceFrames + 1);
|
||||
}
|
||||
|
||||
double instant = votes / 3.0;
|
||||
probability = 0.6 * probability + 0.4 * instant;
|
||||
frameCount++;
|
||||
return probability;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.ts3client.audio;
|
||||
|
||||
/**
|
||||
* Transient (keystroke) suppressor — the "Typing attenuation" stage, which per
|
||||
* the TeamSpeak 3 client "tries to detect and reduce the sounds made by typing"
|
||||
* (WebRTC's {@code transient_suppression} module). Key clicks are short, impulsive,
|
||||
* broadband bursts with a strong high-frequency component, unlike voiced speech which
|
||||
* is sustained and low-frequency dominant.
|
||||
*
|
||||
* <p>Each STFT block is scored for a keystroke signature: a sudden jump in total
|
||||
* power (both against the previous block and a slow running floor) together with an
|
||||
* elevated high-frequency energy ratio. Matching blocks are ducked broadband with an
|
||||
* immediate attack and a short release. A hold cap ensures only genuinely brief
|
||||
* events are cut — a sustained sound such as a fricative outlasts the cap and is
|
||||
* released, so speech is preserved.
|
||||
*/
|
||||
final class TypingAttenuator {
|
||||
|
||||
private static final double HF_HZ = 4000.0; // high-frequency band start
|
||||
private static final double ONSET_FACTOR = 2.5; // total power vs slow floor
|
||||
private static final double FLUX_FACTOR = 3.0; // total power vs previous block
|
||||
private static final double HF_RATIO = 0.30; // fraction of energy above HF_HZ
|
||||
private static final double SUPPRESS = 0.12; // ducking gain on a detected click (~ -18 dB)
|
||||
private static final double RELEASE = 0.25; // recovery fraction per block after a click
|
||||
private static final double FLOOR_SMOOTH = 0.98; // slow power-floor tracking
|
||||
private static final int MAX_HOLD = 4; // max consecutive ducked blocks (~clicks only)
|
||||
|
||||
private final int bins;
|
||||
private final int hfBin;
|
||||
|
||||
private double slowPower;
|
||||
private double prevPower;
|
||||
private double envGain = 1.0;
|
||||
private int heldBlocks;
|
||||
|
||||
TypingAttenuator(int bins, int sampleRate, int fftSize) {
|
||||
this.bins = bins;
|
||||
this.hfBin = (int) Math.round(HF_HZ * fftSize / sampleRate);
|
||||
}
|
||||
|
||||
void reset() {
|
||||
slowPower = 0;
|
||||
prevPower = 0;
|
||||
envGain = 1.0;
|
||||
heldBlocks = 0;
|
||||
}
|
||||
|
||||
/** Multiplies the running per-bin gain by the current broadband ducking gain. */
|
||||
void apply(double[] power, double[] gain) {
|
||||
double total = 0, high = 0;
|
||||
for (int k = 0; k < bins; k++) {
|
||||
total += power[k];
|
||||
if (k >= hfBin) high += power[k];
|
||||
}
|
||||
double hfRatio = high / (total + 1e-12);
|
||||
|
||||
boolean signature = total > slowPower * ONSET_FACTOR
|
||||
&& total > prevPower * FLUX_FACTOR
|
||||
&& hfRatio > HF_RATIO;
|
||||
|
||||
if (signature && heldBlocks < MAX_HOLD) {
|
||||
envGain = SUPPRESS; // fast attack: duck immediately
|
||||
heldBlocks++;
|
||||
} else {
|
||||
envGain += (1 - envGain) * RELEASE;
|
||||
if (!signature) {
|
||||
heldBlocks = 0;
|
||||
// Only let the floor track when we're not inside a transient.
|
||||
slowPower = slowPower == 0 ? total : FLOOR_SMOOTH * slowPower + (1 - FLOOR_SMOOTH) * total;
|
||||
}
|
||||
}
|
||||
prevPower = total;
|
||||
|
||||
for (int k = 0; k < bins; k++) {
|
||||
gain[k] *= envGain;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.ts3client.audio;
|
||||
|
||||
import com.github.manevolent.ts3j.audio.Microphone;
|
||||
import com.ts3client.config.Settings;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Voice capture source feeding the TS3 socket. Extends ts3j's {@link Microphone}
|
||||
* (the encoded-packet supplier) with capture lifecycle and voice-gating controls
|
||||
* so the connection layer can drive it without knowing the platform backend.
|
||||
*/
|
||||
public interface VoiceInput extends Microphone {
|
||||
|
||||
void start();
|
||||
|
||||
void stop();
|
||||
|
||||
void setMuted(boolean muted);
|
||||
|
||||
void setMode(Settings.InputMode mode);
|
||||
|
||||
void setVadMode(Settings.VadMode mode);
|
||||
|
||||
/** Voice-activation volume-gate threshold in dBFS. */
|
||||
void setThresholdDb(double db);
|
||||
|
||||
/** Speech-probability threshold (0..1) for Automatic/Hybrid modes. */
|
||||
void setSpeechThreshold(double threshold);
|
||||
|
||||
/** Keep voice activation active while in push-to-talk mode. */
|
||||
void setVadOverPtt(boolean enabled);
|
||||
|
||||
void setInputGain(double gain);
|
||||
|
||||
/** Enables removal of steady background noise (spectral denoise). */
|
||||
void setNoiseSuppression(boolean enabled);
|
||||
|
||||
/** Background-noise removal aggressiveness, 0 (light) .. 1 (heavy). */
|
||||
void setDenoiserLevel(double level);
|
||||
|
||||
/** Enables detection and attenuation of keyboard typing sounds. */
|
||||
void setTypingAttenuation(boolean enabled);
|
||||
|
||||
/** Enables automatic gain control (normalise microphone loudness). */
|
||||
void setAgc(boolean enabled);
|
||||
|
||||
/** Applies Opus encoder parameters, taking effect immediately if capturing. */
|
||||
void setOpusParameters(OpusParameters parameters);
|
||||
|
||||
void setPushToTalk(boolean pressed);
|
||||
|
||||
/** Receives the live input level in dBFS, once per captured frame. */
|
||||
void setLevelListener(Consumer<Double> listener);
|
||||
|
||||
/** Receives local transmit-state transitions (talking / silent). */
|
||||
void setTalkListener(Consumer<Boolean> listener);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.ts3client.audio;
|
||||
|
||||
import com.github.manevolent.ts3j.protocol.packet.PacketBody0Voice;
|
||||
import com.github.manevolent.ts3j.protocol.packet.PacketBody1VoiceWhisper;
|
||||
|
||||
import java.util.function.BiConsumer;
|
||||
|
||||
/**
|
||||
* Playback sink for incoming voice packets. The connection layer registers
|
||||
* {@link #handleVoice} as the socket's voice handler; the backend decodes and
|
||||
* renders per speaker.
|
||||
*/
|
||||
public interface VoiceOutput {
|
||||
|
||||
void handleVoice(PacketBody0Voice voice);
|
||||
|
||||
/** Handles a whisper (targeted voice) packet; decoded and played like normal voice. */
|
||||
void handleWhisper(PacketBody1VoiceWhisper whisper);
|
||||
|
||||
void setMasterVolume(double volume);
|
||||
|
||||
void setDeafened(boolean deafened);
|
||||
|
||||
boolean isDeafened();
|
||||
|
||||
void setClientMuted(int clientId, boolean muted);
|
||||
|
||||
boolean isClientMuted(int clientId);
|
||||
|
||||
void setOutputDevice(String device);
|
||||
|
||||
void removeClient(int clientId);
|
||||
|
||||
void shutdown();
|
||||
|
||||
/** Receives remote speaker talk-state transitions as (clientId, talking). */
|
||||
void setTalkListener(BiConsumer<Integer, Boolean> listener);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.ts3client.config;
|
||||
|
||||
/** A saved server for quick connect. */
|
||||
public final class Bookmark {
|
||||
public String label;
|
||||
public String address;
|
||||
public int port = 9987;
|
||||
public String nickname;
|
||||
public String password = "";
|
||||
|
||||
public Bookmark() {
|
||||
}
|
||||
|
||||
public Bookmark(String label, String address, int port, String nickname, String password) {
|
||||
this.label = label;
|
||||
this.address = address;
|
||||
this.port = port;
|
||||
this.nickname = nickname;
|
||||
this.password = password == null ? "" : password;
|
||||
}
|
||||
|
||||
/** Display name, falling back to "address:port" when no label is set. */
|
||||
public String displayName() {
|
||||
if (label != null && !label.isBlank()) return label;
|
||||
return address + ":" + port;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return displayName();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.ts3client.config;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
|
||||
/**
|
||||
* Persistent list of {@link Bookmark}s, stored alongside the settings file.
|
||||
* Frontend-agnostic: no UI dependencies.
|
||||
*/
|
||||
public final class Bookmarks {
|
||||
|
||||
private static final File DIR = new File(System.getProperty("user.home"), ".ts3jclient");
|
||||
private static final File FILE = new File(DIR, "bookmarks.properties");
|
||||
|
||||
private final List<Bookmark> entries = new ArrayList<>();
|
||||
|
||||
public List<Bookmark> all() {
|
||||
return entries;
|
||||
}
|
||||
|
||||
public void add(Bookmark b) {
|
||||
entries.add(b);
|
||||
}
|
||||
|
||||
public void remove(int index) {
|
||||
if (index >= 0 && index < entries.size()) entries.remove(index);
|
||||
}
|
||||
|
||||
public static Bookmarks load() {
|
||||
Bookmarks b = new Bookmarks();
|
||||
if (!FILE.isFile()) return b;
|
||||
Properties p = new Properties();
|
||||
try (FileInputStream in = new FileInputStream(FILE)) {
|
||||
p.load(in);
|
||||
} catch (Exception e) {
|
||||
return b;
|
||||
}
|
||||
int count = parseInt(p.getProperty("count"), 0);
|
||||
for (int i = 0; i < count; i++) {
|
||||
String prefix = "bookmark." + i + ".";
|
||||
Bookmark bm = new Bookmark();
|
||||
bm.label = p.getProperty(prefix + "label", "");
|
||||
bm.address = p.getProperty(prefix + "address", "");
|
||||
bm.port = parseInt(p.getProperty(prefix + "port"), 9987);
|
||||
bm.nickname = p.getProperty(prefix + "nickname", "");
|
||||
bm.password = p.getProperty(prefix + "password", "");
|
||||
if (bm.address != null && !bm.address.isBlank()) b.entries.add(bm);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
public void save() {
|
||||
Properties p = new Properties();
|
||||
p.setProperty("count", Integer.toString(entries.size()));
|
||||
for (int i = 0; i < entries.size(); i++) {
|
||||
Bookmark bm = entries.get(i);
|
||||
String prefix = "bookmark." + i + ".";
|
||||
p.setProperty(prefix + "label", nullToEmpty(bm.label));
|
||||
p.setProperty(prefix + "address", nullToEmpty(bm.address));
|
||||
p.setProperty(prefix + "port", Integer.toString(bm.port));
|
||||
p.setProperty(prefix + "nickname", nullToEmpty(bm.nickname));
|
||||
p.setProperty(prefix + "password", nullToEmpty(bm.password));
|
||||
}
|
||||
try {
|
||||
if (!DIR.isDirectory()) {
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
DIR.mkdirs();
|
||||
}
|
||||
try (FileOutputStream out = new FileOutputStream(FILE)) {
|
||||
p.store(out, "TS3J client bookmarks");
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private static int parseInt(String v, int def) {
|
||||
if (v == null) return def;
|
||||
try {
|
||||
return Integer.parseInt(v.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return def;
|
||||
}
|
||||
}
|
||||
|
||||
private static String nullToEmpty(String s) {
|
||||
return s == null ? "" : s;
|
||||
}
|
||||
}
|
||||
220
ts3-client/core/src/main/java/com/ts3client/config/Settings.java
Normal file
220
ts3-client/core/src/main/java/com/ts3client/config/Settings.java
Normal file
@@ -0,0 +1,220 @@
|
||||
package com.ts3client.config;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.util.Properties;
|
||||
|
||||
/**
|
||||
* Simple persistent settings backed by a properties file in the user's home
|
||||
* directory (~/.ts3jclient/settings.properties).
|
||||
*
|
||||
* <p>Holds connection defaults, audio device selection and voice-activation
|
||||
* parameters. Loaded once at startup and saved whenever the user changes
|
||||
* something in the options dialog.
|
||||
*/
|
||||
public final class Settings {
|
||||
|
||||
/** How the microphone decides when to transmit. */
|
||||
public enum InputMode {
|
||||
/** Transmit whenever voice activation detects speech. */
|
||||
VOICE_ACTIVATION,
|
||||
/** Transmit only while the push-to-talk key is held. */
|
||||
PUSH_TO_TALK,
|
||||
/** Always transmit (continuous). */
|
||||
CONTINUOUS
|
||||
}
|
||||
|
||||
/** Voice-activation strategy, mirroring the TS3 client's VAD modes. */
|
||||
public enum VadMode {
|
||||
/** Speech-probability detector only. */
|
||||
AUTOMATIC,
|
||||
/** Volume gate (RMS threshold) only. */
|
||||
VOLUME_GATE,
|
||||
/** Volume gate combined with speech probability. */
|
||||
HYBRID
|
||||
}
|
||||
|
||||
private static final File DIR = new File(System.getProperty("user.home"), ".ts3jclient");
|
||||
private static final File FILE = new File(DIR, "settings.properties");
|
||||
|
||||
private final Properties props = new Properties();
|
||||
|
||||
// ---- connection ----
|
||||
public String lastAddress = "localhost";
|
||||
public String nickname = System.getProperty("user.name", "TS3J User");
|
||||
public String serverPassword = "";
|
||||
public String identityFile = new File(DIR, "identity.ini").getAbsolutePath();
|
||||
|
||||
// ---- audio devices (mixer names; empty = system default) ----
|
||||
public String inputDevice = "";
|
||||
public String outputDevice = "";
|
||||
|
||||
// ---- voice ----
|
||||
public InputMode inputMode = InputMode.VOICE_ACTIVATION;
|
||||
/** Voice-activation strategy used when {@link #inputMode} is VOICE_ACTIVATION. */
|
||||
public VadMode vadMode = VadMode.HYBRID;
|
||||
/** VAD threshold in dBFS (RMS). Typical range -60 (sensitive) .. -10 (loud). */
|
||||
public double vadThresholdDb = -45.0;
|
||||
/** Speech-probability threshold (0..1) for Automatic/Hybrid modes. */
|
||||
public double speechThreshold = 0.5;
|
||||
/** Keep voice activation running while in push-to-talk mode. */
|
||||
public boolean vadOverPtt = false;
|
||||
/** Push-to-talk key as an AWT virtual-key code; the frontend interprets it. Default: Ctrl (VK_CONTROL). */
|
||||
public int pushToTalkKey = 17;
|
||||
/** Opus target bitrate in bits/sec. */
|
||||
public int bitrate = 48000;
|
||||
/** Opus complexity 0..10. */
|
||||
public int complexity = 10;
|
||||
/** Opus variable bitrate. */
|
||||
public boolean vbr = true;
|
||||
/** Opus in-band forward error correction. */
|
||||
public boolean fec = true;
|
||||
/** Expected packet loss percent (drives FEC). */
|
||||
public int packetLoss = 5;
|
||||
/** Encode as music (OPUS_MUSIC) rather than voice (OPUS_VOICE). */
|
||||
public boolean music = false;
|
||||
/** Master playback gain, 0..1 (may exceed 1 for boost up to 2). */
|
||||
public double outputVolume = 1.0;
|
||||
/** Microphone input gain multiplier applied before VAD/encode. */
|
||||
public double inputVolume = 1.0;
|
||||
/** Remove steady background noise from the microphone (spectral denoise). */
|
||||
public boolean denoise = true;
|
||||
/** Background-noise removal aggressiveness, 0 (light) .. 1 (heavy). */
|
||||
public double denoiserLevel = 0.5;
|
||||
/** Detect and attenuate keyboard typing sounds in the microphone. */
|
||||
public boolean typingAttenuation = true;
|
||||
/** Automatic gain control: normalise microphone loudness to a target level. */
|
||||
public boolean agc = true;
|
||||
|
||||
public static Settings load() {
|
||||
Settings s = new Settings();
|
||||
try {
|
||||
if (FILE.isFile()) {
|
||||
try (FileInputStream in = new FileInputStream(FILE)) {
|
||||
s.props.load(in);
|
||||
}
|
||||
s.applyFromProps();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Corrupt/unreadable settings -> fall back to defaults.
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
public void save() {
|
||||
try {
|
||||
if (!DIR.isDirectory()) {
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
DIR.mkdirs();
|
||||
}
|
||||
writeToProps();
|
||||
try (FileOutputStream out = new FileOutputStream(FILE)) {
|
||||
props.store(out, "TS3J Swing Client settings");
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public File identityFile() {
|
||||
return new File(identityFile);
|
||||
}
|
||||
|
||||
public File configDir() {
|
||||
return DIR;
|
||||
}
|
||||
|
||||
private void applyFromProps() {
|
||||
lastAddress = props.getProperty("lastAddress", lastAddress);
|
||||
nickname = props.getProperty("nickname", nickname);
|
||||
serverPassword = props.getProperty("serverPassword", serverPassword);
|
||||
identityFile = props.getProperty("identityFile", identityFile);
|
||||
inputDevice = props.getProperty("inputDevice", inputDevice);
|
||||
outputDevice = props.getProperty("outputDevice", outputDevice);
|
||||
inputMode = parseMode(props.getProperty("inputMode"), inputMode);
|
||||
vadMode = parseVadMode(props.getProperty("vadMode"), vadMode);
|
||||
vadThresholdDb = parseD(props.getProperty("vadThresholdDb"), vadThresholdDb);
|
||||
speechThreshold = parseD(props.getProperty("speechThreshold"), speechThreshold);
|
||||
vadOverPtt = parseB(props.getProperty("vadOverPtt"), vadOverPtt);
|
||||
pushToTalkKey = parseI(props.getProperty("pushToTalkKey"), pushToTalkKey);
|
||||
bitrate = parseI(props.getProperty("bitrate"), bitrate);
|
||||
complexity = parseI(props.getProperty("complexity"), complexity);
|
||||
vbr = parseB(props.getProperty("vbr"), vbr);
|
||||
fec = parseB(props.getProperty("fec"), fec);
|
||||
packetLoss = parseI(props.getProperty("packetLoss"), packetLoss);
|
||||
music = parseB(props.getProperty("music"), music);
|
||||
outputVolume = parseD(props.getProperty("outputVolume"), outputVolume);
|
||||
inputVolume = parseD(props.getProperty("inputVolume"), inputVolume);
|
||||
denoise = parseB(props.getProperty("denoise"), denoise);
|
||||
denoiserLevel = parseD(props.getProperty("denoiserLevel"), denoiserLevel);
|
||||
typingAttenuation = parseB(props.getProperty("typingAttenuation"), typingAttenuation);
|
||||
agc = parseB(props.getProperty("agc"), agc);
|
||||
}
|
||||
|
||||
private void writeToProps() {
|
||||
props.setProperty("lastAddress", lastAddress);
|
||||
props.setProperty("nickname", nickname);
|
||||
props.setProperty("serverPassword", serverPassword);
|
||||
props.setProperty("identityFile", identityFile);
|
||||
props.setProperty("inputDevice", inputDevice);
|
||||
props.setProperty("outputDevice", outputDevice);
|
||||
props.setProperty("inputMode", inputMode.name());
|
||||
props.setProperty("vadMode", vadMode.name());
|
||||
props.setProperty("vadThresholdDb", Double.toString(vadThresholdDb));
|
||||
props.setProperty("speechThreshold", Double.toString(speechThreshold));
|
||||
props.setProperty("vadOverPtt", Boolean.toString(vadOverPtt));
|
||||
props.setProperty("pushToTalkKey", Integer.toString(pushToTalkKey));
|
||||
props.setProperty("bitrate", Integer.toString(bitrate));
|
||||
props.setProperty("complexity", Integer.toString(complexity));
|
||||
props.setProperty("vbr", Boolean.toString(vbr));
|
||||
props.setProperty("fec", Boolean.toString(fec));
|
||||
props.setProperty("packetLoss", Integer.toString(packetLoss));
|
||||
props.setProperty("music", Boolean.toString(music));
|
||||
props.setProperty("outputVolume", Double.toString(outputVolume));
|
||||
props.setProperty("inputVolume", Double.toString(inputVolume));
|
||||
props.setProperty("denoise", Boolean.toString(denoise));
|
||||
props.setProperty("denoiserLevel", Double.toString(denoiserLevel));
|
||||
props.setProperty("typingAttenuation", Boolean.toString(typingAttenuation));
|
||||
props.setProperty("agc", Boolean.toString(agc));
|
||||
}
|
||||
|
||||
private static InputMode parseMode(String v, InputMode def) {
|
||||
if (v == null) return def;
|
||||
try {
|
||||
return InputMode.valueOf(v);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return def;
|
||||
}
|
||||
}
|
||||
|
||||
private static VadMode parseVadMode(String v, VadMode def) {
|
||||
if (v == null) return def;
|
||||
try {
|
||||
return VadMode.valueOf(v);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return def;
|
||||
}
|
||||
}
|
||||
|
||||
private static double parseD(String v, double def) {
|
||||
if (v == null) return def;
|
||||
try {
|
||||
return Double.parseDouble(v);
|
||||
} catch (NumberFormatException e) {
|
||||
return def;
|
||||
}
|
||||
}
|
||||
|
||||
private static int parseI(String v, int def) {
|
||||
if (v == null) return def;
|
||||
try {
|
||||
return Integer.parseInt(v);
|
||||
} catch (NumberFormatException e) {
|
||||
return def;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean parseB(String v, boolean def) {
|
||||
return v == null ? def : Boolean.parseBoolean(v);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.ts3client.net;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Mutable view-model of a TeamSpeak channel. */
|
||||
public final class ChannelNode {
|
||||
public final int id;
|
||||
public int parentId;
|
||||
public int order;
|
||||
public String name;
|
||||
public String topic = "";
|
||||
public String description = "";
|
||||
public boolean descriptionLoaded;
|
||||
public boolean hasPassword;
|
||||
public boolean permanent;
|
||||
public int maxClients = -1;
|
||||
|
||||
/** Populated when the tree is rebuilt. */
|
||||
public final List<ChannelNode> children = new ArrayList<>();
|
||||
public final List<ClientEntry> clients = new ArrayList<>();
|
||||
|
||||
public ChannelNode(int id, String name) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.ts3client.net;
|
||||
|
||||
/** Mutable view-model of a connected client. */
|
||||
public final class ClientEntry {
|
||||
public final int id;
|
||||
public int channelId;
|
||||
public String nickname;
|
||||
public String uniqueId = "";
|
||||
public int type; // 0 = normal voice client, 1 = server-query
|
||||
public int talkPower;
|
||||
|
||||
public int[] serverGroupIds = new int[0];
|
||||
public int channelGroupId;
|
||||
|
||||
// Filled on demand from clientinfo.
|
||||
public String platform = "";
|
||||
public String version = "";
|
||||
public long idleTimeMs;
|
||||
public String description = "";
|
||||
|
||||
public boolean talking;
|
||||
public boolean inputMuted; // microphone muted (client_input_muted)
|
||||
public boolean outputMuted; // speakers muted / deafened (client_output_muted)
|
||||
public boolean away;
|
||||
public boolean channelCommander;
|
||||
public boolean self;
|
||||
|
||||
public ClientEntry(int id, String nickname) {
|
||||
this.id = id;
|
||||
this.nickname = nickname;
|
||||
}
|
||||
|
||||
public boolean isQuery() {
|
||||
return type == 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.ts3client.net;
|
||||
|
||||
/**
|
||||
* UI-facing callbacks fired by {@link TeamspeakConnection}. Implementations are
|
||||
* responsible for marshalling to the Swing EDT.
|
||||
*/
|
||||
public interface ConnectionListener {
|
||||
|
||||
/** Message scope for chat display. */
|
||||
enum ChatScope {SERVER, CHANNEL, PRIVATE}
|
||||
|
||||
void onStatus(String status);
|
||||
|
||||
void onConnected();
|
||||
|
||||
void onDisconnected(String reason);
|
||||
|
||||
/** The channel/client model changed and any tree view should be rebuilt. */
|
||||
void onModelChanged();
|
||||
|
||||
/** On-demand channel/client details finished loading; refresh any info view. */
|
||||
void onInfoUpdated();
|
||||
|
||||
void onChat(ChatScope scope, int fromClientId, String fromName, String message);
|
||||
|
||||
/** A client (possibly the local one) started or stopped talking. */
|
||||
void onTalkStateChanged(int clientId, boolean talking);
|
||||
|
||||
void onError(String message);
|
||||
|
||||
/** Someone poked the local client. */
|
||||
void onPoke(String fromName, String message);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.ts3client.net;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A snapshot of a client's connection statistics, as shown in the Connection
|
||||
* Info window. Values default to {@code -1} meaning "unknown / not reported",
|
||||
* which callers should render as a dash.
|
||||
*
|
||||
* <p>For the local client the figures are read live from the socket's own
|
||||
* packet counters and are fully populated, including the per-{@link Kind}
|
||||
* breakdown. For a remote client the figures come from the server's
|
||||
* {@code notifyconnectioninfo} report and only the aggregate totals are
|
||||
* available; {@link #perKind} is then empty.
|
||||
*/
|
||||
public final class ConnectionStats {
|
||||
|
||||
/** The three traffic categories TeamSpeak accounts separately. */
|
||||
public enum Kind {KEEPALIVE, CONTROL, SPEECH}
|
||||
|
||||
public int clientId;
|
||||
public String nickname = "";
|
||||
public boolean self;
|
||||
|
||||
/** True when derived from local live counters (self); false for a server snapshot. */
|
||||
public boolean live;
|
||||
|
||||
public String ip = "";
|
||||
public String version = "";
|
||||
public String platform = "";
|
||||
|
||||
public double pingMs = -1;
|
||||
public double pingDeviationMs = -1;
|
||||
/** Total packet loss as a fraction 0..1, or -1 if unknown. */
|
||||
public double packetLoss = -1;
|
||||
public long connectedTimeMs = -1;
|
||||
public long idleTimeMs = -1;
|
||||
|
||||
public long filetransferBandwidthSent = -1;
|
||||
public long filetransferBandwidthReceived = -1;
|
||||
|
||||
// Aggregate totals across all traffic kinds.
|
||||
public long packetsSentTotal = -1;
|
||||
public long packetsReceivedTotal = -1;
|
||||
public long bytesSentTotal = -1;
|
||||
public long bytesReceivedTotal = -1;
|
||||
public long bandwidthSentLastSecond = -1;
|
||||
public long bandwidthReceivedLastSecond = -1;
|
||||
public long bandwidthSentLastMinute = -1;
|
||||
public long bandwidthReceivedLastMinute = -1;
|
||||
|
||||
/** Per-category breakdown (one row per {@link Kind}); empty when unavailable. */
|
||||
public final List<KindStats> perKind = new ArrayList<>();
|
||||
|
||||
public boolean hasBreakdown() {
|
||||
return !perKind.isEmpty();
|
||||
}
|
||||
|
||||
/** Returns the stats for {@code kind}, or {@code null} if not present. */
|
||||
public KindStats kind(Kind kind) {
|
||||
for (KindStats k : perKind) {
|
||||
if (k.kind == kind) return k;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Returns the stats for {@code kind}, creating and registering the row if needed. */
|
||||
public KindStats getOrCreateKind(Kind kind) {
|
||||
KindStats existing = kind(kind);
|
||||
if (existing != null) return existing;
|
||||
KindStats created = new KindStats(kind);
|
||||
perKind.add(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
/** One category's counters. Values default to {@code -1} meaning "unknown". */
|
||||
public static final class KindStats {
|
||||
public final Kind kind;
|
||||
public double packetLoss = -1; // fraction 0..1
|
||||
public long packetsSent = -1;
|
||||
public long packetsReceived = -1;
|
||||
public long bytesSent = -1;
|
||||
public long bytesReceived = -1;
|
||||
public long bandwidthSentLastSecond = -1;
|
||||
public long bandwidthReceivedLastSecond = -1;
|
||||
public long bandwidthSentLastMinute = -1;
|
||||
public long bandwidthReceivedLastMinute = -1;
|
||||
|
||||
public KindStats(Kind kind) {
|
||||
this.kind = kind;
|
||||
}
|
||||
}
|
||||
}
|
||||
158
ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java
Normal file
158
ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java
Normal file
@@ -0,0 +1,158 @@
|
||||
package com.ts3client.net;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Thread-safe holder for the current server state (channels + clients).
|
||||
*
|
||||
* <p>Mutated from ts3j's event thread and read from the Swing EDT while building
|
||||
* the tree, so all access is synchronised on the instance.
|
||||
*/
|
||||
public final class ServerModel {
|
||||
|
||||
private final Map<Integer, ChannelNode> channels = new LinkedHashMap<>();
|
||||
private final Map<Integer, ClientEntry> clients = new LinkedHashMap<>();
|
||||
private final Map<Integer, String> serverGroups = new LinkedHashMap<>();
|
||||
private final Map<Integer, String> channelGroups = new LinkedHashMap<>();
|
||||
private String serverName = "TeamSpeak Server";
|
||||
|
||||
public synchronized void clear() {
|
||||
channels.clear();
|
||||
clients.clear();
|
||||
serverGroups.clear();
|
||||
channelGroups.clear();
|
||||
}
|
||||
|
||||
// ---- groups ----
|
||||
|
||||
public synchronized void putServerGroup(int id, String name) {
|
||||
if (name != null) serverGroups.put(id, name);
|
||||
}
|
||||
|
||||
public synchronized void putChannelGroup(int id, String name) {
|
||||
if (name != null) channelGroups.put(id, name);
|
||||
}
|
||||
|
||||
public synchronized String channelGroupName(int id) {
|
||||
return channelGroups.get(id);
|
||||
}
|
||||
|
||||
/** Resolves server-group ids to their names, keeping unknown ids as "#id". */
|
||||
public synchronized java.util.List<String> serverGroupNames(int[] ids) {
|
||||
java.util.List<String> names = new java.util.ArrayList<>();
|
||||
if (ids != null) {
|
||||
for (int id : ids) {
|
||||
String name = serverGroups.get(id);
|
||||
names.add(name != null ? name : "#" + id);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Primary (first) server-group name for compact display, or {@code null}. */
|
||||
public synchronized String primaryServerGroupName(int[] ids) {
|
||||
if (ids == null || ids.length == 0) return null;
|
||||
return serverGroups.get(ids[0]);
|
||||
}
|
||||
|
||||
public synchronized String getServerName() {
|
||||
return serverName;
|
||||
}
|
||||
|
||||
public synchronized void setServerName(String name) {
|
||||
if (name != null && !name.isEmpty()) this.serverName = name;
|
||||
}
|
||||
|
||||
// ---- channels ----
|
||||
|
||||
public synchronized ChannelNode putChannel(int id, String name, int parentId, int order) {
|
||||
ChannelNode c = channels.computeIfAbsent(id, k -> new ChannelNode(id, name));
|
||||
if (name != null) c.name = name;
|
||||
c.parentId = parentId;
|
||||
c.order = order;
|
||||
return c;
|
||||
}
|
||||
|
||||
public synchronized ChannelNode getChannel(int id) {
|
||||
return channels.get(id);
|
||||
}
|
||||
|
||||
public synchronized void removeChannel(int id) {
|
||||
channels.remove(id);
|
||||
}
|
||||
|
||||
// ---- clients ----
|
||||
|
||||
public synchronized ClientEntry putClient(int id, String nickname, int channelId) {
|
||||
ClientEntry c = clients.computeIfAbsent(id, k -> new ClientEntry(id, nickname));
|
||||
if (nickname != null) c.nickname = nickname;
|
||||
c.channelId = channelId;
|
||||
return c;
|
||||
}
|
||||
|
||||
public synchronized ClientEntry getClient(int id) {
|
||||
return clients.get(id);
|
||||
}
|
||||
|
||||
public synchronized void removeClient(int id) {
|
||||
clients.remove(id);
|
||||
}
|
||||
|
||||
public synchronized ClientEntry findClientByName(String name) {
|
||||
for (ClientEntry c : clients.values()) {
|
||||
if (c.nickname != null && c.nickname.equals(name)) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an ordered forest of channels (each with its clients attached), sorted
|
||||
* by TS3's channel order chain and then by client talk power / name.
|
||||
*
|
||||
* @return the list of root channels (parentId == 0)
|
||||
*/
|
||||
public synchronized List<ChannelNode> buildTree() {
|
||||
// Reset transient child/client lists.
|
||||
for (ChannelNode c : channels.values()) {
|
||||
c.children.clear();
|
||||
c.clients.clear();
|
||||
}
|
||||
List<ChannelNode> roots = new ArrayList<>();
|
||||
for (ChannelNode c : channels.values()) {
|
||||
ChannelNode parent = channels.get(c.parentId);
|
||||
if (c.parentId == 0 || parent == null) {
|
||||
roots.add(c);
|
||||
} else {
|
||||
parent.children.add(c);
|
||||
}
|
||||
}
|
||||
for (ClientEntry cl : clients.values()) {
|
||||
if (cl.isQuery()) continue; // hide server-query clients from the tree
|
||||
ChannelNode ch = channels.get(cl.channelId);
|
||||
if (ch != null) ch.clients.add(cl);
|
||||
}
|
||||
|
||||
Comparator<ChannelNode> byOrder = Comparator.comparingInt((ChannelNode c) -> c.order)
|
||||
.thenComparing(c -> c.name == null ? "" : c.name.toLowerCase());
|
||||
Comparator<ClientEntry> byClient = Comparator
|
||||
.comparingInt((ClientEntry c) -> -c.talkPower)
|
||||
.thenComparing(c -> c.nickname == null ? "" : c.nickname.toLowerCase());
|
||||
|
||||
roots.sort(byOrder);
|
||||
for (ChannelNode c : channels.values()) {
|
||||
c.children.sort(byOrder);
|
||||
c.clients.sort(byClient);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
public synchronized int clientCount() {
|
||||
int n = 0;
|
||||
for (ClientEntry c : clients.values()) if (!c.isQuery()) n++;
|
||||
return n;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,860 @@
|
||||
package com.ts3client.net;
|
||||
|
||||
import com.github.manevolent.ts3j.api.Channel;
|
||||
import com.github.manevolent.ts3j.api.Client;
|
||||
import com.github.manevolent.ts3j.command.SingleCommand;
|
||||
import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter;
|
||||
import com.github.manevolent.ts3j.event.*;
|
||||
import com.github.manevolent.ts3j.identity.LocalIdentity;
|
||||
import com.github.manevolent.ts3j.protocol.PacketKind;
|
||||
import com.github.manevolent.ts3j.protocol.ProtocolRole;
|
||||
import com.github.manevolent.ts3j.protocol.packet.statistics.PacketStatistics;
|
||||
import com.github.manevolent.ts3j.protocol.socket.client.LocalTeamspeakClientSocket;
|
||||
import com.github.manevolent.ts3j.util.Pair;
|
||||
import com.ts3client.audio.AudioBackend;
|
||||
import com.ts3client.audio.VoiceInput;
|
||||
import com.ts3client.audio.VoiceOutput;
|
||||
import com.ts3client.config.Settings;
|
||||
|
||||
import java.io.File;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* High-level facade that ties the ts3j protocol socket to the audio subsystem and
|
||||
* the {@link ServerModel}, translating protocol events into {@link ConnectionListener}
|
||||
* callbacks for the UI.
|
||||
*/
|
||||
public final class TeamspeakConnection implements TS3Listener {
|
||||
|
||||
private final Settings settings;
|
||||
private final AudioBackend audio;
|
||||
private final ServerModel model = new ServerModel();
|
||||
private final ConnectionListener ui;
|
||||
|
||||
private LocalTeamspeakClientSocket client;
|
||||
private VoiceInput microphone;
|
||||
private VoiceOutput playback;
|
||||
private LocalIdentity identity;
|
||||
|
||||
private volatile boolean connected;
|
||||
private volatile int selfClientId = -1;
|
||||
private volatile long connectedAtMs;
|
||||
|
||||
/** Outstanding {@code getconnectioninfo} requests awaiting a server report, keyed by client id. */
|
||||
private final Map<Integer, PendingConnInfo> pendingConnInfo = new ConcurrentHashMap<>();
|
||||
|
||||
public TeamspeakConnection(Settings settings, AudioBackend audio, ConnectionListener ui) {
|
||||
this.settings = settings;
|
||||
this.audio = audio;
|
||||
this.ui = ui;
|
||||
}
|
||||
|
||||
public ServerModel getModel() {
|
||||
return model;
|
||||
}
|
||||
|
||||
public boolean isConnected() {
|
||||
return connected;
|
||||
}
|
||||
|
||||
public int getSelfClientId() {
|
||||
return selfClientId;
|
||||
}
|
||||
|
||||
public VoiceInput getMicrophone() {
|
||||
return microphone;
|
||||
}
|
||||
|
||||
public VoiceOutput getPlayback() {
|
||||
return playback;
|
||||
}
|
||||
|
||||
// ---- identity ----
|
||||
|
||||
/** Loads the persisted identity, or generates and saves a new one. */
|
||||
public LocalIdentity loadOrCreateIdentity() throws Exception {
|
||||
File file = settings.identityFile();
|
||||
if (file.isFile()) {
|
||||
try {
|
||||
return LocalIdentity.read(file);
|
||||
} catch (Exception e) {
|
||||
// fall through and regenerate
|
||||
}
|
||||
}
|
||||
LocalIdentity id = LocalIdentity.generateNew(10);
|
||||
if (!file.getParentFile().isDirectory()) {
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
file.getParentFile().mkdirs();
|
||||
}
|
||||
id.save(file);
|
||||
return id;
|
||||
}
|
||||
|
||||
// ---- connection lifecycle ----
|
||||
|
||||
public void connect(String address, int port, String nickname, String password) {
|
||||
new Thread(() -> doConnect(address, port, nickname, password), "ts3j-connect").start();
|
||||
}
|
||||
|
||||
private void doConnect(String address, int port, String nickname, String password) {
|
||||
try {
|
||||
ui.onStatus("Loading identity…");
|
||||
identity = loadOrCreateIdentity();
|
||||
|
||||
model.clear();
|
||||
playback = audio.createOutput(settings);
|
||||
playback.setMasterVolume(settings.outputVolume);
|
||||
playback.setTalkListener((clientId, talking) -> {
|
||||
ClientEntry c = model.getClient(clientId);
|
||||
if (c != null) c.talking = talking;
|
||||
ui.onTalkStateChanged(clientId, talking);
|
||||
});
|
||||
|
||||
microphone = audio.createInput(settings);
|
||||
microphone.setTalkListener(talking -> {
|
||||
if (selfClientId >= 0) {
|
||||
ClientEntry c = model.getClient(selfClientId);
|
||||
if (c != null) c.talking = talking;
|
||||
ui.onTalkStateChanged(selfClientId, talking);
|
||||
}
|
||||
});
|
||||
|
||||
client = new LocalTeamspeakClientSocket();
|
||||
client.setIdentity(identity);
|
||||
client.setNickname(nickname);
|
||||
client.setHWID("ts3jswing-" + Integer.toHexString(nickname.hashCode()));
|
||||
client.addListener(this);
|
||||
client.setVoiceHandler(playback::handleVoice);
|
||||
client.setWhisperHandler(playback::handleWhisper);
|
||||
client.setExceptionHandler(t -> {
|
||||
// A late packet dispatched after close() rejects on the shut-down
|
||||
// command executor; harmless during teardown.
|
||||
if (t instanceof java.util.concurrent.RejectedExecutionException) return;
|
||||
ui.onError("Network error: " + rootMessage(t));
|
||||
});
|
||||
|
||||
ui.onStatus("Connecting to " + address + ":" + port + "…");
|
||||
client.connect(new InetSocketAddress(address, port),
|
||||
(password == null || password.isEmpty()) ? null : password,
|
||||
10_000L);
|
||||
|
||||
// Protocol connection established; anything past this point is best-effort.
|
||||
selfClientId = client.getClientId();
|
||||
client.setMicrophone(microphone);
|
||||
connected = true;
|
||||
connectedAtMs = System.currentTimeMillis();
|
||||
ui.onConnected();
|
||||
|
||||
ui.onStatus("Retrieving channels…");
|
||||
syncAll();
|
||||
ui.onModelChanged();
|
||||
ui.onStatus("Connected to " + model.getServerName());
|
||||
|
||||
try {
|
||||
microphone.start();
|
||||
} catch (Exception micError) {
|
||||
ui.onError("Microphone unavailable: " + rootMessage(micError));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
connected = false;
|
||||
ui.onError("Connection failed: " + rootMessage(e));
|
||||
ui.onStatus("Disconnected");
|
||||
safeCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Populates the model from the server. Each step is best-effort: a restricted
|
||||
* guest group may deny {@code channelsubscribeall} or the list commands without
|
||||
* that being a fatal connection error, and channel data already arrives via
|
||||
* events during {@code connect()}.
|
||||
*/
|
||||
private void syncAll() {
|
||||
try {
|
||||
client.subscribeAll();
|
||||
} catch (Exception e) {
|
||||
ui.onStatus("Limited subscription: " + rootMessage(e));
|
||||
}
|
||||
try {
|
||||
for (Channel ch : client.listChannels()) {
|
||||
model.putChannel(ch.getId(), ch.getName(), ch.getParentChannelId(), ch.getOrder());
|
||||
ChannelNode node = model.getChannel(ch.getId());
|
||||
if (node != null) {
|
||||
node.hasPassword = ch.hasPassword();
|
||||
node.permanent = ch.isPermanent();
|
||||
node.topic = ch.getTopic() == null ? "" : ch.getTopic();
|
||||
node.maxClients = ch.getMaxClients();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
ui.onStatus("Channel list unavailable: " + rootMessage(e));
|
||||
}
|
||||
try {
|
||||
for (Client cl : client.listClients()) {
|
||||
ClientEntry e = model.putClient(cl.getId(), cl.getNickname(), cl.getChannelId());
|
||||
e.type = cl.getType();
|
||||
e.talkPower = cl.getTalkPower();
|
||||
e.inputMuted = cl.isInputMuted();
|
||||
e.outputMuted = cl.isOutputMuted();
|
||||
e.away = cl.isAway();
|
||||
e.uniqueId = cl.getUniqueIdentifier();
|
||||
e.serverGroupIds = cl.getServerGroups();
|
||||
e.channelGroupId = cl.getChannelGroupId();
|
||||
e.self = (cl.getId() == selfClientId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
ui.onStatus("Client list unavailable: " + rootMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
public void disconnect() {
|
||||
new Thread(() -> disconnectBlocking("Leaving"), "ts3j-disconnect").start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect synchronously on the calling thread, notifying the UI. Used both
|
||||
* by the async {@link #disconnect()} and by shutdown paths (Ctrl+C, window
|
||||
* close) where the JVM must not exit before the leave notification is sent.
|
||||
*/
|
||||
public void disconnectBlocking(String reason) {
|
||||
try {
|
||||
if (client != null) client.disconnect(reason);
|
||||
} catch (Exception ignored) {
|
||||
} finally {
|
||||
safeCleanup();
|
||||
connected = false;
|
||||
ui.onDisconnected("You disconnected");
|
||||
ui.onStatus("Disconnected");
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void safeCleanup() {
|
||||
VoiceInput mic = microphone;
|
||||
VoiceOutput out = playback;
|
||||
LocalTeamspeakClientSocket sock = client;
|
||||
microphone = null;
|
||||
playback = null;
|
||||
client = null;
|
||||
selfClientId = -1;
|
||||
pendingConnInfo.clear();
|
||||
|
||||
if (mic != null) {
|
||||
try {
|
||||
mic.stop();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
if (out != null) {
|
||||
try {
|
||||
out.shutdown();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
if (sock != null) {
|
||||
try {
|
||||
sock.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- self actions ----
|
||||
|
||||
public void setMicMuted(boolean muted) {
|
||||
if (microphone != null) microphone.setMuted(muted);
|
||||
pushSelfFlags();
|
||||
}
|
||||
|
||||
public void setDeafened(boolean deaf) {
|
||||
if (playback != null) playback.setDeafened(deaf);
|
||||
if (microphone != null && deaf) microphone.setMuted(true);
|
||||
pushSelfFlags();
|
||||
}
|
||||
|
||||
private void pushSelfFlags() {
|
||||
// Best-effort: publish input/output muted flags to the server so others see them.
|
||||
if (client == null || !connected || microphone == null || playback == null) return;
|
||||
try {
|
||||
java.util.Map<String, String> props = new java.util.HashMap<>();
|
||||
props.put("client_input_muted", microphone.isMuted() ? "1" : "0");
|
||||
props.put("client_output_muted", playback.isDeafened() ? "1" : "0");
|
||||
client.editClient(selfClientId, props);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public void joinChannel(int channelId, String password) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
client.joinChannel(channelId, (password == null || password.isEmpty()) ? null : password);
|
||||
} catch (Exception e) {
|
||||
ui.onError("Could not join channel: " + rootMessage(e));
|
||||
}
|
||||
}, "ts3j-join").start();
|
||||
}
|
||||
|
||||
public void sendChannelMessage(String text) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
ClientEntry self = model.getClient(selfClientId);
|
||||
int cid = self != null ? self.channelId : 0;
|
||||
client.sendChannelMessage(cid, text);
|
||||
} catch (Exception e) {
|
||||
ui.onError("Message failed: " + rootMessage(e));
|
||||
}
|
||||
}, "ts3j-chan-msg").start();
|
||||
}
|
||||
|
||||
public void sendServerMessage(String text) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
client.sendServerMessage(text);
|
||||
} catch (Exception e) {
|
||||
ui.onError("Message failed: " + rootMessage(e));
|
||||
}
|
||||
}, "ts3j-srv-msg").start();
|
||||
}
|
||||
|
||||
public void sendPrivateMessage(int clientId, String text) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
client.sendPrivateMessage(clientId, text);
|
||||
} catch (Exception e) {
|
||||
ui.onError("Message failed: " + rootMessage(e));
|
||||
}
|
||||
}, "ts3j-pm").start();
|
||||
}
|
||||
|
||||
public void poke(int clientId, String message) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
client.clientPoke(clientId, message);
|
||||
} catch (Exception e) {
|
||||
ui.onError("Poke failed: " + rootMessage(e));
|
||||
}
|
||||
}, "ts3j-poke").start();
|
||||
}
|
||||
|
||||
public void setAway(boolean away, String message) {
|
||||
selfUpdate(cmd -> {
|
||||
cmd.add(new CommandSingleParameter("client_away", away ? "1" : "0"));
|
||||
cmd.add(new CommandSingleParameter("client_away_message", away && message != null ? message : ""));
|
||||
}, "Away update failed");
|
||||
}
|
||||
|
||||
public void setChannelCommander(boolean commander) {
|
||||
selfUpdate(cmd ->
|
||||
cmd.add(new CommandSingleParameter("client_is_channel_commander", commander ? "1" : "0")),
|
||||
"Channel commander update failed");
|
||||
}
|
||||
|
||||
/** Sends a {@code clientupdate} for the local client on a background thread. */
|
||||
private void selfUpdate(java.util.function.Consumer<SingleCommand> fill, String errorLabel) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
SingleCommand cmd = new SingleCommand("clientupdate", ProtocolRole.CLIENT);
|
||||
fill.accept(cmd);
|
||||
client.executeCommand(cmd).complete();
|
||||
} catch (Exception e) {
|
||||
ui.onError(errorLabel + ": " + rootMessage(e));
|
||||
}
|
||||
}, "ts3j-selfupdate").start();
|
||||
}
|
||||
|
||||
public double getPingMillis() {
|
||||
try {
|
||||
return client != null ? client.getPing().getKey() * 1000.0 : -1;
|
||||
} catch (Exception e) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
public void setNickname(String nickname) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
client.setNickname(nickname);
|
||||
} catch (Exception e) {
|
||||
ui.onError("Rename failed: " + rootMessage(e));
|
||||
}
|
||||
}, "ts3j-rename").start();
|
||||
}
|
||||
|
||||
// ---- TS3Listener: keep model in sync and notify UI ----
|
||||
|
||||
@Override
|
||||
public void onClientJoin(ClientJoinEvent e) {
|
||||
ClientEntry c = model.putClient(e.getClientId(), e.getClientNickname(), e.getClientTargetId());
|
||||
c.type = safeInt(e, "client_type");
|
||||
c.talkPower = e.getClientTalkPower();
|
||||
c.inputMuted = e.isClientInputMuted();
|
||||
c.outputMuted = e.isClientOutputMuted();
|
||||
c.away = e.isClientAway();
|
||||
c.uniqueId = orEmpty(e.getUniqueClientIdentifier());
|
||||
c.serverGroupIds = parseIntList(e.getClientServerGroups());
|
||||
c.channelGroupId = e.getClientChannelGroupId();
|
||||
c.self = (e.getClientId() == selfClientId);
|
||||
ui.onModelChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClientLeave(ClientLeaveEvent e) {
|
||||
model.removeClient(e.getClientId());
|
||||
if (playback != null) playback.removeClient(e.getClientId());
|
||||
ui.onModelChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClientMoved(ClientMovedEvent e) {
|
||||
ClientEntry c = model.getClient(e.getClientId());
|
||||
if (c != null) {
|
||||
c.channelId = e.getTargetChannelId();
|
||||
ui.onModelChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClientChanged(ClientUpdatedEvent e) {
|
||||
ClientEntry c = model.getClient(e.getClientId());
|
||||
if (c == null) return;
|
||||
String nick = e.get("client_nickname");
|
||||
if (nick != null) c.nickname = nick;
|
||||
if (e.get("client_input_muted") != null) c.inputMuted = e.getBoolean("client_input_muted");
|
||||
if (e.get("client_output_muted") != null) c.outputMuted = e.getBoolean("client_output_muted");
|
||||
if (e.get("client_away") != null) c.away = e.getBoolean("client_away");
|
||||
if (e.get("client_talk_power") != null) c.talkPower = e.getInt("client_talk_power");
|
||||
if (e.get("client_is_channel_commander") != null)
|
||||
c.channelCommander = e.getBoolean("client_is_channel_commander");
|
||||
ui.onModelChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelCreate(ChannelCreateEvent e) {
|
||||
int cid = e.getChannelId();
|
||||
String name = e.get("channel_name");
|
||||
int pid = safeInt(e, "cpid");
|
||||
if (pid == 0) pid = safeInt(e, "pid");
|
||||
int order = safeInt(e, "channel_order");
|
||||
model.putChannel(cid, name, pid, order);
|
||||
ui.onModelChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelDeleted(ChannelDeletedEvent e) {
|
||||
model.removeChannel(e.getChannelId());
|
||||
ui.onModelChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelEdit(ChannelEditedEvent e) {
|
||||
ChannelNode ch = model.getChannel(safeInt(e, "cid"));
|
||||
if (ch != null) {
|
||||
String name = e.get("channel_name");
|
||||
if (name != null) ch.name = name;
|
||||
if (e.get("channel_order") != null) ch.order = e.getInt("channel_order");
|
||||
ui.onModelChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelMoved(ChannelMovedEvent e) {
|
||||
ChannelNode ch = model.getChannel(safeInt(e, "cid"));
|
||||
if (ch != null) {
|
||||
if (e.get("cpid") != null) ch.parentId = e.getInt("cpid");
|
||||
if (e.get("order") != null) ch.order = e.getInt("order");
|
||||
ui.onModelChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelList(ChannelListEvent e) {
|
||||
// Incremental channel arriving during connect.
|
||||
int cid = e.getChannelId();
|
||||
String name = e.get("channel_name");
|
||||
int pid = safeInt(e, "cpid");
|
||||
int order = safeInt(e, "channel_order");
|
||||
model.putChannel(cid, name, pid, order);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServerGroupList(ServerGroupListEvent e) {
|
||||
int id = safeInt(e, "sgid");
|
||||
if (id > 0) model.putServerGroup(id, e.get("name"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelGroupList(ChannelGroupListEvent e) {
|
||||
int id = safeInt(e, "cgid");
|
||||
if (id > 0) model.putChannelGroup(id, e.get("name"));
|
||||
}
|
||||
|
||||
/** Fetches a channel's description (and topic) on demand, then notifies the UI. */
|
||||
public void requestChannelInfo(int channelId) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
Channel ch = client.getChannelInfo(channelId);
|
||||
ChannelNode node = model.getChannel(channelId);
|
||||
if (ch != null && node != null) {
|
||||
node.description = orEmpty(ch.get("channel_description"));
|
||||
node.topic = orEmpty(ch.getTopic());
|
||||
node.descriptionLoaded = true;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// description may be permission-restricted; leave as-is
|
||||
} finally {
|
||||
ui.onInfoUpdated();
|
||||
}
|
||||
}, "ts3j-channelinfo").start();
|
||||
}
|
||||
|
||||
/** Fetches a client's extended info (groups, platform, version) on demand. */
|
||||
public void requestClientInfo(int clientId) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
Client c = client.getClientInfo(clientId);
|
||||
ClientEntry e = model.getClient(clientId);
|
||||
if (c != null && e != null) {
|
||||
e.platform = orEmpty(c.getPlatform());
|
||||
e.version = orEmpty(c.getVersion());
|
||||
e.idleTimeMs = c.getIdleTime();
|
||||
e.description = orEmpty(c.get("client_description"));
|
||||
int[] groups = c.getServerGroups();
|
||||
if (groups != null && groups.length > 0) e.serverGroupIds = groups;
|
||||
e.channelGroupId = c.getChannelGroupId();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
} finally {
|
||||
ui.onInfoUpdated();
|
||||
}
|
||||
}, "ts3j-clientinfo").start();
|
||||
}
|
||||
|
||||
// ---- connection info ----
|
||||
|
||||
/**
|
||||
* Fetches connection statistics for a client and delivers them to
|
||||
* {@code callback} (invoked off the Swing EDT — the callback is responsible
|
||||
* for marshalling). For the local client the figures are read from the live
|
||||
* local packet counters; for a remote client they are requested from the
|
||||
* server via {@code getconnectioninfo}, enriched with {@code clientinfo} for
|
||||
* version, platform and idle time. May be called repeatedly to poll.
|
||||
*/
|
||||
public void requestConnectionInfo(int clientId, Consumer<ConnectionStats> callback) {
|
||||
if (client == null || !connected) return;
|
||||
if (clientId == selfClientId) {
|
||||
new Thread(() -> callback.accept(buildLocalStats()), "ts3j-conninfo-self").start();
|
||||
} else {
|
||||
new Thread(() -> requestRemoteConnInfo(clientId, callback), "ts3j-conninfo").start();
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds a live snapshot of the local client's connection from its own counters. */
|
||||
private ConnectionStats buildLocalStats() {
|
||||
ConnectionStats s = new ConnectionStats();
|
||||
s.clientId = selfClientId;
|
||||
s.self = true;
|
||||
s.live = true;
|
||||
s.packetLoss = 0; // the local client has no server->client loss figure
|
||||
|
||||
ClientEntry self = model.getClient(selfClientId);
|
||||
if (self != null) {
|
||||
s.nickname = self.nickname;
|
||||
s.version = self.version;
|
||||
s.platform = self.platform;
|
||||
s.idleTimeMs = self.idleTimeMs;
|
||||
}
|
||||
long connectedAt = connectedAtMs;
|
||||
if (connectedAt > 0) s.connectedTimeMs = System.currentTimeMillis() - connectedAt;
|
||||
|
||||
LocalTeamspeakClientSocket c = client;
|
||||
if (c == null) return s;
|
||||
|
||||
try {
|
||||
Pair<Double, Double> ping = c.getPing();
|
||||
s.pingMs = ping.getKey() * 1000.0;
|
||||
s.pingDeviationMs = ping.getValue() * 1000.0;
|
||||
} catch (Exception ignored) {
|
||||
// ping unavailable; leave as unknown
|
||||
}
|
||||
|
||||
long pSent = 0, pRecv = 0, bSent = 0, bRecv = 0;
|
||||
long bwSs = 0, bwRs = 0, bwSm = 0, bwRm = 0;
|
||||
for (PacketKind kind : PacketKind.values()) {
|
||||
PacketStatistics st = c.getStatistics(kind);
|
||||
ConnectionStats.KindStats ks = new ConnectionStats.KindStats(mapKind(kind));
|
||||
ks.packetLoss = 0; // the local client cannot measure its own server->client loss
|
||||
ks.packetsSent = st.getSentPackets();
|
||||
ks.packetsReceived = st.getReceivedPackets();
|
||||
ks.bytesSent = st.getSentBytes();
|
||||
ks.bytesReceived = st.getReceivedBytes();
|
||||
ks.bandwidthSentLastSecond = st.getSentBytesLastSecond();
|
||||
ks.bandwidthReceivedLastSecond = st.getReceivedBytesLastSecond();
|
||||
ks.bandwidthSentLastMinute = st.getSentBytesLastMinute();
|
||||
ks.bandwidthReceivedLastMinute = st.getReceivedBytesLastMinute();
|
||||
s.perKind.add(ks);
|
||||
|
||||
pSent += ks.packetsSent;
|
||||
pRecv += ks.packetsReceived;
|
||||
bSent += ks.bytesSent;
|
||||
bRecv += ks.bytesReceived;
|
||||
bwSs += ks.bandwidthSentLastSecond;
|
||||
bwRs += ks.bandwidthReceivedLastSecond;
|
||||
bwSm += ks.bandwidthSentLastMinute;
|
||||
bwRm += ks.bandwidthReceivedLastMinute;
|
||||
}
|
||||
s.packetsSentTotal = pSent;
|
||||
s.packetsReceivedTotal = pRecv;
|
||||
s.bytesSentTotal = bSent;
|
||||
s.bytesReceivedTotal = bRecv;
|
||||
s.bandwidthSentLastSecond = bwSs;
|
||||
s.bandwidthReceivedLastSecond = bwRs;
|
||||
s.bandwidthSentLastMinute = bwSm;
|
||||
s.bandwidthReceivedLastMinute = bwRm;
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a remote client's connection info. First loads {@code clientinfo}
|
||||
* for the stable fields, then issues {@code getconnectioninfo} whose
|
||||
* {@code notifyconnectioninfo} report arrives asynchronously via
|
||||
* {@link #onUnknownEvent}. If the report does not arrive shortly (e.g. the
|
||||
* server withholds it), the clientinfo-only snapshot is delivered instead.
|
||||
*/
|
||||
private void requestRemoteConnInfo(int clientId, Consumer<ConnectionStats> callback) {
|
||||
ConnectionStats s = new ConnectionStats();
|
||||
s.clientId = clientId;
|
||||
ClientEntry entry = model.getClient(clientId);
|
||||
if (entry != null) s.nickname = entry.nickname;
|
||||
|
||||
try {
|
||||
Client c = client.getClientInfo(clientId);
|
||||
if (c != null) {
|
||||
s.version = orEmpty(c.getVersion());
|
||||
s.platform = orEmpty(c.getPlatform());
|
||||
s.ip = orEmpty(c.getIp());
|
||||
s.idleTimeMs = c.getIdleTime();
|
||||
if (entry == null) s.nickname = orEmpty(c.getNickname());
|
||||
applyConnectionFields(s, c.getMap());
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// clientinfo may be permission-restricted; continue with what we have
|
||||
}
|
||||
|
||||
PendingConnInfo pending = new PendingConnInfo(callback, s);
|
||||
pendingConnInfo.put(clientId, pending);
|
||||
|
||||
boolean sent = false;
|
||||
try {
|
||||
SingleCommand cmd = new SingleCommand("getconnectioninfo", ProtocolRole.CLIENT,
|
||||
new CommandSingleParameter("clid", Integer.toString(clientId)));
|
||||
client.executeCommand(cmd).complete();
|
||||
sent = true;
|
||||
} catch (Exception ignored) {
|
||||
// command failed; fall back to the clientinfo snapshot below
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
try {
|
||||
Thread.sleep(700); // give notifyconnectioninfo a chance to arrive
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
// If the report already arrived, onUnknownEvent removed and delivered it.
|
||||
if (pendingConnInfo.remove(clientId, pending)) {
|
||||
callback.accept(s);
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses TeamSpeak {@code connection_*} fields from a command map into {@code s}. */
|
||||
private static void applyConnectionFields(ConnectionStats s, Map<String, String> m) {
|
||||
double ping = parseDouble(m.get("connection_ping"));
|
||||
if (ping >= 0) s.pingMs = ping;
|
||||
double dev = parseDouble(m.get("connection_ping_deviation"));
|
||||
if (dev >= 0) s.pingDeviationMs = dev;
|
||||
double loss = parseDouble(m.get("connection_packetloss_total"));
|
||||
if (loss < 0) loss = parseDouble(m.get("connection_server2client_packetloss_total"));
|
||||
if (loss >= 0) s.packetLoss = loss;
|
||||
|
||||
long connected = parseLong(m.get("connection_connected_time"));
|
||||
if (connected >= 0) s.connectedTimeMs = connected;
|
||||
String ip = m.get("connection_client_ip");
|
||||
if (ip != null && !ip.isEmpty()) s.ip = ip;
|
||||
|
||||
s.packetsSentTotal = pick(s.packetsSentTotal, m.get("connection_packets_sent_total"));
|
||||
s.packetsReceivedTotal = pick(s.packetsReceivedTotal, m.get("connection_packets_received_total"));
|
||||
s.bytesSentTotal = pick(s.bytesSentTotal, m.get("connection_bytes_sent_total"));
|
||||
s.bytesReceivedTotal = pick(s.bytesReceivedTotal, m.get("connection_bytes_received_total"));
|
||||
s.bandwidthSentLastSecond =
|
||||
pick(s.bandwidthSentLastSecond, m.get("connection_bandwidth_sent_last_second_total"));
|
||||
s.bandwidthReceivedLastSecond =
|
||||
pick(s.bandwidthReceivedLastSecond, m.get("connection_bandwidth_received_last_second_total"));
|
||||
s.bandwidthSentLastMinute =
|
||||
pick(s.bandwidthSentLastMinute, m.get("connection_bandwidth_sent_last_minute_total"));
|
||||
s.bandwidthReceivedLastMinute =
|
||||
pick(s.bandwidthReceivedLastMinute, m.get("connection_bandwidth_received_last_minute_total"));
|
||||
s.filetransferBandwidthSent =
|
||||
pick(s.filetransferBandwidthSent, m.get("connection_filetransfer_bandwidth_sent"));
|
||||
s.filetransferBandwidthReceived =
|
||||
pick(s.filetransferBandwidthReceived, m.get("connection_filetransfer_bandwidth_received"));
|
||||
|
||||
applyPerKindFields(s, m);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the per-category {@code connection_*_<kind>} fields (as sent in a
|
||||
* {@code notifyconnectioninfo} report) into {@code s}, merging into any
|
||||
* existing rows. Categories absent from the map are left untouched.
|
||||
*/
|
||||
private static void applyPerKindFields(ConnectionStats s, Map<String, String> m) {
|
||||
for (ConnectionStats.Kind kind : ConnectionStats.Kind.values()) {
|
||||
String suffix = kind.name().toLowerCase(java.util.Locale.ROOT); // keepalive/control/speech
|
||||
String probe = m.get("connection_packets_sent_" + suffix);
|
||||
String probe2 = m.get("connection_server2client_packetloss_" + suffix);
|
||||
if (probe == null && probe2 == null) continue; // this category not reported
|
||||
|
||||
ConnectionStats.KindStats ks = s.getOrCreateKind(kind);
|
||||
ks.packetsSent = pick(ks.packetsSent, m.get("connection_packets_sent_" + suffix));
|
||||
ks.packetsReceived = pick(ks.packetsReceived, m.get("connection_packets_received_" + suffix));
|
||||
ks.bytesSent = pick(ks.bytesSent, m.get("connection_bytes_sent_" + suffix));
|
||||
ks.bytesReceived = pick(ks.bytesReceived, m.get("connection_bytes_received_" + suffix));
|
||||
ks.bandwidthSentLastSecond =
|
||||
pick(ks.bandwidthSentLastSecond, m.get("connection_bandwidth_sent_last_second_" + suffix));
|
||||
ks.bandwidthReceivedLastSecond =
|
||||
pick(ks.bandwidthReceivedLastSecond, m.get("connection_bandwidth_received_last_second_" + suffix));
|
||||
ks.bandwidthSentLastMinute =
|
||||
pick(ks.bandwidthSentLastMinute, m.get("connection_bandwidth_sent_last_minute_" + suffix));
|
||||
ks.bandwidthReceivedLastMinute =
|
||||
pick(ks.bandwidthReceivedLastMinute, m.get("connection_bandwidth_received_last_minute_" + suffix));
|
||||
double kloss = parseDouble(m.get("connection_server2client_packetloss_" + suffix));
|
||||
if (kloss >= 0) ks.packetLoss = kloss;
|
||||
}
|
||||
}
|
||||
|
||||
private static long pick(long current, String value) {
|
||||
long v = parseLong(value);
|
||||
return v >= 0 ? v : current;
|
||||
}
|
||||
|
||||
private static ConnectionStats.Kind mapKind(PacketKind kind) {
|
||||
switch (kind) {
|
||||
case KEEPALIVE:
|
||||
return ConnectionStats.Kind.KEEPALIVE;
|
||||
case SPEECH:
|
||||
return ConnectionStats.Kind.SPEECH;
|
||||
default:
|
||||
return ConnectionStats.Kind.CONTROL;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUnknownEvent(UnknownTeamspeakEvent e) {
|
||||
if (!"notifyconnectioninfo".equals(e.getCommand())) return;
|
||||
int clid = safeInt(e, "clid");
|
||||
PendingConnInfo pending = pendingConnInfo.remove(clid);
|
||||
if (pending == null) return;
|
||||
applyConnectionFields(pending.stats, e.getMap());
|
||||
pending.callback.accept(pending.stats);
|
||||
}
|
||||
|
||||
/** Callback + accumulating snapshot for an in-flight {@code getconnectioninfo}. */
|
||||
private static final class PendingConnInfo {
|
||||
final Consumer<ConnectionStats> callback;
|
||||
final ConnectionStats stats;
|
||||
|
||||
PendingConnInfo(Consumer<ConnectionStats> callback, ConnectionStats stats) {
|
||||
this.callback = callback;
|
||||
this.stats = stats;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTextMessage(TextMessageEvent e) {
|
||||
if (e.getInvokerId() == selfClientId) return; // don't echo our own
|
||||
ConnectionListener.ChatScope scope;
|
||||
switch (e.getTargetMode()) {
|
||||
case CLIENT:
|
||||
scope = ConnectionListener.ChatScope.PRIVATE;
|
||||
break;
|
||||
case CHANNEL:
|
||||
scope = ConnectionListener.ChatScope.CHANNEL;
|
||||
break;
|
||||
default:
|
||||
scope = ConnectionListener.ChatScope.SERVER;
|
||||
break;
|
||||
}
|
||||
ui.onChat(scope, e.getInvokerId(), e.getInvokerName(), e.getMessage());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClientPoke(ClientPokeEvent e) {
|
||||
ui.onPoke(orEmpty(e.getInvokerName()), orEmpty(e.get("msg")));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisconnected(DisconnectedEvent e) {
|
||||
connected = false;
|
||||
safeCleanup();
|
||||
ui.onDisconnected(orEmpty(e.getReasonMessage()));
|
||||
ui.onStatus("Disconnected");
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static int safeInt(BaseEvent e, String key) {
|
||||
try {
|
||||
String v = e.get(key);
|
||||
return v == null ? 0 : Integer.parseInt(v.trim());
|
||||
} catch (Exception ex) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static String orEmpty(String s) {
|
||||
return s == null ? "" : s;
|
||||
}
|
||||
|
||||
/** Parses a long, returning -1 for null/blank/non-numeric input. */
|
||||
private static long parseLong(String s) {
|
||||
if (s == null || s.isEmpty()) return -1;
|
||||
try {
|
||||
return Long.parseLong(s.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses a double, returning -1 for null/blank/non-numeric input. */
|
||||
private static double parseDouble(String s) {
|
||||
if (s == null || s.isEmpty()) return -1;
|
||||
try {
|
||||
return Double.parseDouble(s.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses a comma-separated id list (e.g. server groups "6,12,15"). */
|
||||
private static int[] parseIntList(String csv) {
|
||||
if (csv == null || csv.isEmpty()) return new int[0];
|
||||
String[] parts = csv.split(",");
|
||||
int[] out = new int[parts.length];
|
||||
int n = 0;
|
||||
for (String p : parts) {
|
||||
try {
|
||||
out[n++] = Integer.parseInt(p.trim());
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
return n == parts.length ? out : java.util.Arrays.copyOf(out, n);
|
||||
}
|
||||
|
||||
private static String rootMessage(Throwable t) {
|
||||
Throwable r = t;
|
||||
while (r.getCause() != null && r.getCause() != r) r = r.getCause();
|
||||
String m = r.getMessage();
|
||||
return m != null ? m : r.getClass().getSimpleName();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user