Initial commit: TS3J TeamSpeak 3 Java client

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

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

23
ts3-client/core/pom.xml Normal file
View File

@@ -0,0 +1,23 @@
<?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-core</artifactId>
<name>TS3J Client Core</name>
<description>Frontend-agnostic library: protocol integration, model, audio abstractions</description>
<dependencies>
<dependency>
<groupId>com.github.manevolent</groupId>
<artifactId>ts3j</artifactId>
</dependency>
</dependencies>
</project>

View File

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

View File

@@ -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&nbsp;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") &mdash; sharing one FFT/IFFT per hop &mdash; 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&nbsp;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;
}
}
}

View File

@@ -0,0 +1,66 @@
package com.ts3client.audio;
/**
* Automatic gain control &mdash; 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);
}
}

View File

@@ -0,0 +1,68 @@
package com.ts3client.audio;
/**
* In-place iterative radix-2 Cooley&ndash;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;
}
}
}
}
}

View File

@@ -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 &mdash; 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;
}
}
}

View File

@@ -0,0 +1,112 @@
package com.ts3client.audio;
import java.util.Arrays;
/**
* Single-channel spectral noise suppressor &mdash; the "Remove background noise"
* (denoise) stage. The TeamSpeak&nbsp;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 &mdash; which dips between syllables &mdash; 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&nbsp;priori SNR
* (Ephraim&ndash;Malah smoothing, which keeps musical noise low). A configurable
* aggressiveness ({@code denoiser_level}, 0&ndash;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&nbsp;dB max cut), 1 is
* heavy (~30&nbsp;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);
}
}

View File

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

View File

@@ -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&nbsp;ms frame from three features — short-term
* energy, spectral flatness and dominant frequency — against an adaptive noise
* floor, following Moattar &amp; 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;
}
}

View File

@@ -0,0 +1,78 @@
package com.ts3client.audio;
/**
* Transient (keystroke) suppressor &mdash; the "Typing attenuation" stage, which per
* the TeamSpeak&nbsp;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 &mdash; 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;
}
}
}

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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