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

154
ts3-client/README.md Normal file
View File

@@ -0,0 +1,154 @@
# TS3J Client
A desktop TeamSpeak 3 client built on top of the [`ts3j`](../ts3j) reverse-engineered
TS3 protocol library. It looks and behaves like the official TS3 client and focuses
first on the features that matter most: **real-time voice with Opus encoding and
voice-activation detection (VAD)**.
The project is split so the frontend can be replaced (e.g. a future web UI via
TeaVM/CheerpJ) without touching the library:
| Module | Artifact | Responsibility |
|--------|----------|----------------|
| `core` | `ts3-client-core` | Frontend-agnostic library: protocol integration, server model, connection orchestration, audio **abstractions**. No UI, no platform audio, no JNA. |
| `desktop` | `ts3-client-desktop` | Desktop audio backend: Java Sound capture/playback + native Opus via JNA, implementing the core audio interfaces. |
| `swing` | `ts3-client-swing` | Swing desktop UI + entry point. Depends on `core` and `desktop`. |
The core exposes `AudioBackend` / `VoiceInput` / `VoiceOutput`; the frontend injects
a concrete backend (`JavaSoundAudioBackend`) into `TeamspeakConnection`. A different
frontend supplies its own UI and audio backend while reusing `core` unchanged.
## Features
### Voice (the priority)
- **Native Opus codec** via a direct JNA binding to the system `libopus`
(no bundled/native-jar dependency). Encoding at 48 kHz, 20 ms frames.
- **Voice Activation Detection (VAD)** with the same three modes as the TS3 client:
- **Volume Gate** — RMS/dBFS threshold with a live input meter and hangover.
- **Automatic** — a dependency-free speech detector (short-term energy + spectral
flatness + dominant frequency against an adaptive noise floor).
- **Hybrid** — transmit only when loud enough **and** detected as speech.
- Optional **VAD over Push-To-Talk** (keep detecting voice while PTT is available).
- **Capture pre-processing** — a mini audio-processing chain in the same order as the
TS3 client's WebRTC APM, applied before activation and encoding (and feeding the VAD):
input gain → **high-pass filter** (80 Hz, always-on rumble/DC removal) → **noise
suppression** → **typing attenuation****AGC**:
- **Remove background noise** — spectral denoiser (decision-directed Wiener with
minimum-statistics noise tracking), with an adjustable removal level.
- **Typing attenuation** — detects impulsive keystroke transients (short, broadband,
high-frequency bursts) and ducks them while leaving sustained speech intact.
- **Automatic gain control (AGC)** — normalises mic loudness to a target level
(fast attack / slow release, noise-gated so silence is never amplified).
- Echo cancellation (WebRTC AEC3) is intentionally omitted — it needs the loudspeaker
reference signal and matters mainly for open speakers, not the typical headset.
- **Push-to-Talk** — bind any key; transmits only while held (while the app is focused).
- **Continuous** transmission mode.
- **Playback mixing** — each speaker gets its own Opus decoder and audio line, so
multiple simultaneous talkers are mixed and one slow decode never blocks others.
- **Whisper receive** — targeted voice is decoded and played like normal voice.
- Per-client **mute**, master **deafen**, adjustable mic gain / playback volume.
- **On-the-fly Opus tuning** — bitrate, complexity, VBR, FEC and voice/music codec
can all be changed live from the options dialog and take effect on the running
encoder immediately (no reconnect); a voice↔music switch transparently rebuilds
the encoder because Opus fixes its application mode at creation.
- Configurable capture/playback **devices**.
### Server interaction
- Connect to any TS3 server (auto-generates and persists a TS3 identity).
- **Channel/client tree** styled like TS3, updated live from protocol events
(joins, leaves, moves, channel create/edit/delete, nickname/mute/away changes).
- **Talk indicators** — speakers turn green live as they talk.
- **Info panel** — selecting a channel shows its topic and **description** (fetched
on demand); selecting a client shows its **server groups** and **channel group**
(names resolved from the server's group lists), talk power, platform and version.
- **Server-group badge** next to each client's nickname in the tree.
- **Spacer channels** — TS3 `[spacer]`/`[*spacer]`/`[c/l/r spacer]` names render as
non-interactive separators (fill, centred, aligned).
- Double-click a channel to **join**; right-click a client to **poke**, open a
**private chat**, or **locally mute** them.
- **Chat** to the current channel or the whole server; receive channel/server/private
messages and **pokes**.
- **Server bookmarks** — quick-connect menu with add/edit/remove management.
- **Self status** — Away (with message) and Channel Commander toggles.
- **Status bar** shows the server name, user count and live ping.
- Change your nickname, mute/deafen from the toolbar.
## Requirements
- Java 17+ (developed/tested on Temurin 26)
- The native Opus library on the system:
- Debian/Ubuntu: `sudo apt install libopus0`
- Arch: `sudo pacman -S opus`
- macOS: `brew install opus`
## Building
The client depends on `ts3j`, so install that into your local Maven repo first:
```bash
cd ../ts3j && mvn -DskipTests install
cd ../ts3-client && mvn -DskipTests package
```
This produces a runnable fat-jar at `swing/target/ts3-client.jar`.
## Running
```bash
java -jar swing/target/ts3-client.jar
# or, during development:
mvn -pl swing exec:java
```
Then use **Connections → Connect…**, enter a server address, port (default 9987)
and nickname, and connect. Open **Tools → Options** to pick audio devices and tune
voice activation while watching the live meter.
## Architecture
```
core/ com.ts3client
├── config.Settings persisted prefs (~/.ts3jclient/settings.properties)
├── config.Bookmarks persisted server bookmarks
├── audio abstractions + reusable DSP (no platform code)
│ ├── AudioBackend factory for a platform's VoiceInput/VoiceOutput
│ ├── VoiceInput capture source (extends ts3j Microphone) + gating controls
│ ├── VoiceOutput voice-packet playback sink
│ ├── OpusParameters live-tunable encoder settings
│ └── SpeechDetector feature-based speech-probability VAD (Automatic/Hybrid)
└── net
├── TeamspeakConnection ties socket + audio backend + model, translates events
├── ServerModel thread-safe channel/client state
├── ChannelNode/ClientEntry view models
└── ConnectionListener frontend callbacks
desktop/ com.ts3client.audio.desktop
├── Opus JNA binding to native libopus
├── OpusEncoder/OpusDecoder thin codec wrappers
├── AudioDevices device enumeration + line opening (48 kHz/16-bit)
├── JavaSoundVoiceInput capture + VAD/PTT gating + Opus encode
├── JavaSoundVoiceOutput per-client Opus decode + playback + mixing
└── JavaSoundAudioBackend wires the above into the core AudioBackend
swing/ com.ts3client
├── Main entry point (look & feel, settings, backend injection)
└── ui
├── MainFrame window: menu, toolbar, tree | chat, status bar
├── ServerTreePanel TS3-style channel/client tree (group badges, spacers)
├── Spacers TS3 spacer-channel name parsing/rendering
├── InfoPanel channel description / client group + details view
├── ChatPanel chat log + input
├── SettingsDialog audio + VAD options with live meter
├── ConnectDialog connect form
├── BookmarksDialog manage saved servers
├── LevelMeter dBFS meter with threshold marker
├── Icons programmatic vector icons (no image assets)
└── Theme palette + fonts
```
## Known limitations / next steps
- The **Automatic/Hybrid** VAD uses a lightweight energy/spectral detector rather
than the WebRTC GMM model the official client ships; it is intentionally
dependency-free and reusable in the core library.
- Group display shows names; **group icons** are not rendered.
- Whisper is received/played but not yet **sendable** from the UI.
- Playback decodes streams as mono; stereo music-bot audio is down-mixed.
- Push-to-talk is captured via Swing key events, so it only works while the app
window has focus (no global hotkey).
- No file transfer, avatars, or server/channel administration UI yet.

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,61 @@
package com.ts3client.audio.desktop;
import com.sun.jna.Library;
import com.sun.jna.Native;
import com.sun.jna.ptr.PointerByReference;
import java.nio.IntBuffer;
/**
* Minimal JNA binding to the native Opus codec library (libopus).
*
* <p>We bind directly to the system-installed {@code libopus.so}/{@code opus.dll}
* rather than relying on a bundled wrapper. Opus always operates internally at
* 48&nbsp;kHz which matches what the TeamSpeak 3 protocol uses on the wire.
*/
public interface Opus extends Library {
Opus INSTANCE = Native.load("opus", Opus.class);
// ---- application types (opus_defines.h) ----
int OPUS_APPLICATION_VOIP = 2048;
int OPUS_APPLICATION_AUDIO = 2049;
int OPUS_APPLICATION_RESTRICTED_LOWDELAY = 2051;
// ---- CTL request codes ----
int OPUS_SET_BITRATE_REQUEST = 4002;
int OPUS_SET_VBR_REQUEST = 4006;
int OPUS_SET_COMPLEXITY_REQUEST = 4010;
int OPUS_SET_INBAND_FEC_REQUEST = 4012;
int OPUS_SET_PACKET_LOSS_PERC_REQUEST = 4014;
int OPUS_SET_SIGNAL_REQUEST = 4024;
int OPUS_RESET_STATE = 4028;
// ---- signal hints ----
int OPUS_AUTO = -1000;
int OPUS_SIGNAL_VOICE = 3001;
int OPUS_SIGNAL_MUSIC = 3002;
// ---- encoder ----
PointerByReference opus_encoder_create(int fs, int channels, int application, IntBuffer error);
int opus_encode_float(PointerByReference st, float[] pcm, int frameSize, byte[] data, int maxDataBytes);
int opus_encoder_ctl(PointerByReference st, int request, Object... args);
void opus_encoder_destroy(PointerByReference st);
// ---- decoder ----
PointerByReference opus_decoder_create(int fs, int channels, IntBuffer error);
int opus_decode_float(PointerByReference st, byte[] data, int len, float[] pcm, int frameSize, int decodeFec);
int opus_decoder_ctl(PointerByReference st, int request, Object... args);
void opus_decoder_destroy(PointerByReference st);
// ---- misc ----
String opus_get_version_string();
String opus_strerror(int error);
}

View File

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

View File

@@ -0,0 +1,101 @@
package com.ts3client.audio.desktop;
import com.sun.jna.ptr.PointerByReference;
import java.nio.IntBuffer;
/**
* Thin wrapper around a native Opus encoder configured for TeamSpeak voice.
*
* <p>Fixed at 48&nbsp;kHz. Frames are 20&nbsp;ms (960 samples per channel), which
* is the frame size the TS3 client uses.
*/
public final class OpusEncoder implements AutoCloseable {
private final PointerByReference handle;
private final int frameSize;
private final int channels;
private final byte[] out = new byte[4096];
private final Object lock = new Object();
private boolean closed;
public OpusEncoder(int sampleRate, int frameSize, int channels, int application) {
this.frameSize = frameSize;
this.channels = channels;
IntBuffer error = IntBuffer.allocate(1);
handle = Opus.INSTANCE.opus_encoder_create(sampleRate, channels, application, error);
if (handle == null || error.get(0) != 0) {
throw new IllegalStateException("opus_encoder_create failed: "
+ Opus.INSTANCE.opus_strerror(error.get(0)));
}
}
public void setBitrate(int bitsPerSecond) {
ctl(Opus.OPUS_SET_BITRATE_REQUEST, bitsPerSecond);
}
public void setComplexity(int complexity) {
ctl(Opus.OPUS_SET_COMPLEXITY_REQUEST, Math.max(0, Math.min(10, complexity)));
}
public void setVbr(boolean vbr) {
ctl(Opus.OPUS_SET_VBR_REQUEST, vbr ? 1 : 0);
}
public void setInbandFec(boolean fec) {
ctl(Opus.OPUS_SET_INBAND_FEC_REQUEST, fec ? 1 : 0);
}
public void setExpectedPacketLoss(int percent) {
ctl(Opus.OPUS_SET_PACKET_LOSS_PERC_REQUEST, Math.max(0, Math.min(100, percent)));
}
public void setSignal(int signal) {
ctl(Opus.OPUS_SET_SIGNAL_REQUEST, signal);
}
private void ctl(int request, int value) {
synchronized (lock) {
if (closed) return;
int r = Opus.INSTANCE.opus_encoder_ctl(handle, request, value);
if (r < 0) {
throw new IllegalStateException("opus_encoder_ctl(" + request + ") failed: "
+ Opus.INSTANCE.opus_strerror(r));
}
}
}
/**
* Encodes one frame of interleaved float PCM ({@code frameSize * channels} samples)
* into an Opus packet.
*
* @return a newly allocated byte array holding the encoded packet
*/
public byte[] encode(float[] pcm) {
if (pcm.length != frameSize * channels) {
throw new IllegalArgumentException("expected " + (frameSize * channels)
+ " samples, got " + pcm.length);
}
synchronized (lock) {
if (closed) throw new IllegalStateException("encoder closed");
int len = Opus.INSTANCE.opus_encode_float(handle, pcm, frameSize, out, out.length);
if (len < 0) {
throw new IllegalStateException("opus_encode_float failed: "
+ Opus.INSTANCE.opus_strerror(len));
}
byte[] packet = new byte[len];
System.arraycopy(out, 0, packet, 0, len);
return packet;
}
}
@Override
public void close() {
synchronized (lock) {
if (closed) return;
closed = true;
Opus.INSTANCE.opus_encoder_destroy(handle);
}
}
}

52
ts3-client/pom.xml Normal file
View File

@@ -0,0 +1,52 @@
<?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>
<groupId>com.ts3client</groupId>
<artifactId>ts3-client-parent</artifactId>
<version>0.1.0</version>
<packaging>pom</packaging>
<name>TS3J Client (parent)</name>
<description>Desktop TeamSpeak 3 client built on the ts3j protocol library</description>
<modules>
<module>core</module>
<module>desktop</module>
<module>swing</module>
</modules>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<ts3j.version>1.0.3</ts3j.version>
<jna.version>5.14.0</jna.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.github.manevolent</groupId>
<artifactId>ts3j</artifactId>
<version>${ts3j.version}</version>
</dependency>
<dependency>
<groupId>net.java.dev.jna</groupId>
<artifactId>jna</artifactId>
<version>${jna.version}</version>
</dependency>
<dependency>
<groupId>com.ts3client</groupId>
<artifactId>ts3-client-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.ts3client</groupId>
<artifactId>ts3-client-desktop</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>

84
ts3-client/swing/pom.xml Normal file
View File

@@ -0,0 +1,84 @@
<?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-swing</artifactId>
<name>TS3J Client Swing Frontend</name>
<description>Swing desktop UI</description>
<properties>
<exec.mainClass>com.ts3client.Main</exec.mainClass>
</properties>
<dependencies>
<dependency>
<groupId>com.ts3client</groupId>
<artifactId>ts3-client-core</artifactId>
</dependency>
<dependency>
<groupId>com.ts3client</groupId>
<artifactId>ts3-client-desktop</artifactId>
</dependency>
<dependency>
<groupId>com.github.manevolent</groupId>
<artifactId>ts3j</artifactId>
</dependency>
</dependencies>
<build>
<finalName>ts3-client</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
<exclude>module-info.class</exclude>
</excludes>
</filter>
</filters>
<transformers>
<transformer
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.ts3client.Main</mainClass>
<manifestEntries>
<Enable-Native-Access>ALL-UNNAMED</Enable-Native-Access>
</manifestEntries>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.1.0</version>
<configuration>
<mainClass>com.ts3client.Main</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,33 @@
package com.ts3client;
import com.ts3client.config.Settings;
import com.ts3client.ui.MainFrame;
import javax.swing.SwingUtilities;
import javax.swing.UIManager;
/**
* Application entry point. Loads settings, applies the system look-and-feel and
* shows the main window on the Swing event dispatch thread.
*/
public final class Main {
public static void main(String[] args) {
// ts3j is verbose by default; keep the console quiet unless debugging.
try {
com.github.manevolent.ts3j.util.Ts3Debugging.setEnabled(false);
} catch (Throwable ignored) {
}
final Settings settings = Settings.load();
SwingUtilities.invokeLater(() -> {
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
} catch (Exception ignored) {
// fall back to cross-platform L&F
}
new MainFrame(settings).setVisible(true);
});
}
}

View File

@@ -0,0 +1,155 @@
package com.ts3client.ui;
import com.ts3client.config.Bookmark;
import com.ts3client.config.Bookmarks;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.DefaultListModel;
import javax.swing.JButton;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JPasswordField;
import javax.swing.JScrollPane;
import javax.swing.JTextField;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.Frame;
import java.awt.GridLayout;
import java.util.function.Consumer;
/** Manage saved servers: add, edit, remove and quick-connect. */
public final class BookmarksDialog extends JDialog {
private final Bookmarks bookmarks;
private final Consumer<Bookmark> onConnect;
private final Runnable onChanged;
private final DefaultListModel<Bookmark> listModel = new DefaultListModel<>();
private final JList<Bookmark> list = new JList<>(listModel);
public BookmarksDialog(Frame owner, Bookmarks bookmarks, Consumer<Bookmark> onConnect, Runnable onChanged) {
super(owner, "Manage Bookmarks", true);
this.bookmarks = bookmarks;
this.onConnect = onConnect;
this.onChanged = onChanged;
reload();
list.setVisibleRowCount(10);
JScrollPane scroll = new JScrollPane(list);
scroll.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
JPanel buttons = new JPanel();
buttons.setLayout(new BoxLayout(buttons, BoxLayout.Y_AXIS));
buttons.setBorder(BorderFactory.createEmptyBorder(8, 0, 8, 8));
addButton(buttons, "Connect", this::connectSelected);
addButton(buttons, "Add…", this::addBookmark);
addButton(buttons, "Edit…", this::editSelected);
addButton(buttons, "Remove", this::removeSelected);
buttons.add(Box.createVerticalGlue());
addButton(buttons, "Close", this::dispose);
getContentPane().setLayout(new BorderLayout());
getContentPane().add(scroll, BorderLayout.CENTER);
getContentPane().add(buttons, BorderLayout.EAST);
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
setSize(new Dimension(420, 300));
setLocationRelativeTo(owner);
}
private void addButton(JPanel panel, String text, Runnable action) {
JButton b = new JButton(text);
b.setAlignmentX(LEFT_ALIGNMENT);
b.setMaximumSize(new Dimension(Integer.MAX_VALUE, b.getPreferredSize().height));
b.addActionListener(e -> action.run());
panel.add(b);
panel.add(Box.createVerticalStrut(4));
}
private void reload() {
listModel.clear();
for (Bookmark b : bookmarks.all()) listModel.addElement(b);
}
private void connectSelected() {
Bookmark b = list.getSelectedValue();
if (b != null) {
onConnect.accept(b);
dispose();
}
}
private void addBookmark() {
Bookmark b = new Bookmark("", "", 9987, System.getProperty("user.name", "TS3J User"), "");
if (promptBookmark(b)) {
bookmarks.add(b);
persistAndRefresh();
}
}
private void editSelected() {
Bookmark b = list.getSelectedValue();
if (b != null && promptBookmark(b)) {
persistAndRefresh();
}
}
private void removeSelected() {
int idx = list.getSelectedIndex();
if (idx >= 0) {
bookmarks.remove(idx);
persistAndRefresh();
}
}
private void persistAndRefresh() {
bookmarks.save();
reload();
if (onChanged != null) onChanged.run();
}
/** Modal add/edit form. Mutates {@code b} and returns whether the user confirmed. */
private boolean promptBookmark(Bookmark b) {
JTextField label = new JTextField(b.label == null ? "" : b.label);
JTextField address = new JTextField(b.address == null ? "" : b.address);
JTextField port = new JTextField(Integer.toString(b.port));
JTextField nick = new JTextField(b.nickname == null ? "" : b.nickname);
JPasswordField password = new JPasswordField(b.password == null ? "" : b.password);
JPanel form = new JPanel(new GridLayout(0, 1, 0, 2));
form.add(new JLabel("Label:"));
form.add(label);
form.add(new JLabel("Address:"));
form.add(address);
form.add(new JLabel("Port:"));
form.add(port);
form.add(new JLabel("Nickname:"));
form.add(nick);
form.add(new JLabel("Password (optional):"));
form.add(password);
int result = JOptionPane.showConfirmDialog(this, form,
"Bookmark", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE);
if (result != JOptionPane.OK_OPTION) return false;
if (address.getText().trim().isEmpty()) {
JOptionPane.showMessageDialog(this, "Address is required.");
return false;
}
b.label = label.getText().trim();
b.address = address.getText().trim();
try {
b.port = Integer.parseInt(port.getText().trim());
} catch (NumberFormatException e) {
b.port = 9987;
}
b.nickname = nick.getText().trim();
b.password = new String(password.getPassword());
return true;
}
}

View File

@@ -0,0 +1,127 @@
package com.ts3client.ui;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextField;
import javax.swing.JTextPane;
import javax.swing.SwingUtilities;
import javax.swing.text.BadLocationException;
import javax.swing.text.SimpleAttributeSet;
import javax.swing.text.StyleConstants;
import javax.swing.text.StyledDocument;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.text.SimpleDateFormat;
import java.util.Date;
/**
* Chat log with an input line and a target selector (current channel or whole
* server). Coloured styling mimics the TS3 chat pane.
*/
public final class ChatPanel extends JPanel {
/** Where an outgoing message should go. */
public enum Target {CHANNEL, SERVER}
public interface SendHandler {
void send(Target target, String text);
}
private final JTextPane log = new JTextPane();
private final JTextField input = new JTextField();
private final JComboBox<String> targetBox = new JComboBox<>(new String[]{"Channel", "Server"});
private final SimpleDateFormat time = new SimpleDateFormat("HH:mm:ss");
private SendHandler sendHandler;
public ChatPanel() {
super(new BorderLayout());
log.setEditable(false);
log.setBackground(Theme.CHAT_BG);
log.setFont(Theme.UI_FONT);
log.setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6));
JScrollPane scroll = new JScrollPane(log);
scroll.setBorder(BorderFactory.createLineBorder(new Color(0xD0D0D0)));
add(scroll, BorderLayout.CENTER);
JPanel bottom = new JPanel(new BorderLayout(4, 0));
bottom.setBorder(BorderFactory.createEmptyBorder(4, 0, 0, 0));
targetBox.setPreferredSize(new Dimension(90, 24));
bottom.add(targetBox, BorderLayout.WEST);
bottom.add(input, BorderLayout.CENTER);
JButton send = new JButton("Send");
bottom.add(send, BorderLayout.EAST);
add(bottom, BorderLayout.SOUTH);
Runnable doSend = this::fireSend;
send.addActionListener(e -> doSend.run());
input.addActionListener(e -> doSend.run());
setInputEnabled(false);
}
public void setSendHandler(SendHandler h) {
this.sendHandler = h;
}
public void setInputEnabled(boolean enabled) {
input.setEnabled(enabled);
targetBox.setEnabled(enabled);
}
private void fireSend() {
String text = input.getText().trim();
if (text.isEmpty() || sendHandler == null) return;
Target t = targetBox.getSelectedIndex() == 1 ? Target.SERVER : Target.CHANNEL;
sendHandler.send(t, text);
input.setText("");
}
// ---- append helpers (safe from any thread) ----
public void appendSystem(String text) {
edt(() -> append("[" + time.format(new Date()) + "] ", Theme.CHAT_SYSTEM, false,
text, Theme.CHAT_SYSTEM, false));
}
public void appendMessage(String from, String text) {
edt(() -> {
append("[" + time.format(new Date()) + "] ", Theme.CHAT_SYSTEM, false, "", Theme.CHAT_SYSTEM, false);
append(from + ": ", Theme.CHAT_NAME, true, text, Theme.CHAT_TEXT, false);
});
}
private void edt(Runnable r) {
if (SwingUtilities.isEventDispatchThread()) r.run();
else SwingUtilities.invokeLater(r);
}
private void append(String prefix, Color prefixColor, boolean prefixBold,
String body, Color bodyColor, boolean bodyBold) {
StyledDocument doc = log.getStyledDocument();
try {
if (prefix != null && !prefix.isEmpty()) {
doc.insertString(doc.getLength(), prefix, style(prefixColor, prefixBold));
}
if (body != null && !body.isEmpty()) {
doc.insertString(doc.getLength(), body, style(bodyColor, bodyBold));
}
doc.insertString(doc.getLength(), "\n", style(bodyColor, false));
log.setCaretPosition(doc.getLength());
} catch (BadLocationException ignored) {
}
}
private static SimpleAttributeSet style(Color c, boolean bold) {
SimpleAttributeSet a = new SimpleAttributeSet();
StyleConstants.setForeground(a, c);
StyleConstants.setBold(a, bold);
StyleConstants.setFontFamily(a, "SansSerif");
StyleConstants.setFontSize(a, 12);
return a;
}
}

View File

@@ -0,0 +1,119 @@
package com.ts3client.ui;
import com.ts3client.config.Settings;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JPasswordField;
import javax.swing.JTextField;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.Frame;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
/** Modal "Connect to Server" dialog. */
public final class ConnectDialog extends JDialog {
private final JTextField addressField;
private final JTextField portField;
private final JTextField nickField;
private final JPasswordField passwordField;
private boolean confirmed;
public ConnectDialog(Frame owner, Settings settings) {
super(owner, "Connect to Server", true);
String addr = settings.lastAddress;
int port = 9987;
int colon = addr.lastIndexOf(':');
if (colon > 0) {
try {
port = Integer.parseInt(addr.substring(colon + 1));
addr = addr.substring(0, colon);
} catch (NumberFormatException ignored) {
}
}
addressField = new JTextField(addr, 18);
portField = new JTextField(Integer.toString(port), 6);
nickField = new JTextField(settings.nickname, 18);
passwordField = new JPasswordField(settings.serverPassword, 18);
JPanel form = new JPanel(new GridBagLayout());
form.setBorder(BorderFactory.createEmptyBorder(12, 12, 8, 12));
GridBagConstraints c = new GridBagConstraints();
c.insets = new Insets(4, 4, 4, 4);
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
int row = 0;
add(form, c, row++, "Server address:", addressField);
add(form, c, row++, "Port:", portField);
add(form, c, row++, "Nickname:", nickField);
add(form, c, row++, "Password (optional):", passwordField);
JPanel buttons = new JPanel(new BorderLayout());
JPanel right = new JPanel();
JButton connect = new JButton("Connect");
JButton cancel = new JButton("Cancel");
connect.addActionListener(e -> {
confirmed = true;
dispose();
});
cancel.addActionListener(e -> dispose());
right.add(connect);
right.add(cancel);
buttons.add(right, BorderLayout.EAST);
getRootPane().setDefaultButton(connect);
getContentPane().setLayout(new BorderLayout());
getContentPane().add(form, BorderLayout.CENTER);
getContentPane().add(buttons, BorderLayout.SOUTH);
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
pack();
setMinimumSize(new Dimension(340, getHeight()));
setLocationRelativeTo(owner);
}
private void add(JPanel form, GridBagConstraints c, int row, String label, java.awt.Component field) {
c.gridx = 0;
c.gridy = row;
c.weightx = 0;
form.add(new JLabel(label), c);
c.gridx = 1;
c.weightx = 1;
form.add(field, c);
}
public boolean isConfirmed() {
return confirmed;
}
public String getAddress() {
return addressField.getText().trim();
}
public int getPort() {
try {
return Integer.parseInt(portField.getText().trim());
} catch (NumberFormatException e) {
return 9987;
}
}
public String getNickname() {
String n = nickField.getText().trim();
return n.isEmpty() ? "TS3J User" : n;
}
public String getPassword() {
return new String(passwordField.getPassword());
}
}

View File

@@ -0,0 +1,326 @@
package com.ts3client.ui;
import com.ts3client.net.ConnectionStats;
import com.ts3client.net.TeamspeakConnection;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JTabbedPane;
import javax.swing.SwingUtilities;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Frame;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.text.DecimalFormat;
/**
* Live "Client Connection Info" window, reachable from a client's right-click
* menu. Mirrors the TeamSpeak 3 dialog: a summary header (address, version,
* platform, idle/connected time, ping, filetransfer) above a
* <em>Total / Speech / Keep Alive / Control</em> tab strip, each tab showing
* that category's packet loss, packet and byte totals and live bandwidth.
*
* <p>Polls the {@link TeamspeakConnection} once a second while open; for the
* local client the figures update live from local counters, for a remote client
* they refresh from the server's connection report.
*/
public final class ConnectionInfoDialog extends JDialog {
private static final int REFRESH_MS = 1000;
private final TeamspeakConnection conn;
private final int clientId;
private final javax.swing.Timer timer;
private final java.util.concurrent.atomic.AtomicBoolean inFlight =
new java.util.concurrent.atomic.AtomicBoolean();
private final JLabel addressValue = value();
private final JLabel versionValue = value();
private final JLabel platformValue = value();
private final JLabel idleValue = value();
private final JLabel connectedValue = value();
private final JLabel pingValue = value();
private final JLabel filetransferValue = value();
private final KindTab totalTab = new KindTab();
private final KindTab speechTab = new KindTab();
private final KindTab keepAliveTab = new KindTab();
private final KindTab controlTab = new KindTab();
public ConnectionInfoDialog(Frame owner, TeamspeakConnection conn, int clientId, String nickname) {
super(owner, "Connection Info — " + nickname, false);
this.conn = conn;
this.clientId = clientId;
JPanel content = new JPanel(new BorderLayout(0, 10));
content.setBackground(Theme.WINDOW_BG);
content.setBorder(BorderFactory.createEmptyBorder(12, 14, 12, 14));
content.add(buildSummary(), BorderLayout.NORTH);
content.add(buildTabs(), BorderLayout.CENTER);
content.add(buildButtons(), BorderLayout.SOUTH);
setContentPane(content);
pack();
setLocationRelativeTo(owner);
addWindowListener(new WindowAdapter() {
@Override
public void windowClosed(WindowEvent e) {
timer.stop();
}
});
timer = new javax.swing.Timer(REFRESH_MS, e -> refresh());
timer.setInitialDelay(0);
timer.start();
}
// ---- construction ----
private JPanel buildSummary() {
JPanel grid = new JPanel(new GridBagLayout());
grid.setBackground(Theme.WINDOW_BG);
int row = 0;
addRow(grid, row++, "Address", addressValue);
addRow(grid, row++, "Client version", versionValue);
addRow(grid, row++, "Platform", platformValue);
addRow(grid, row++, "Ping", pingValue);
addRow(grid, row++, "Idle time", idleValue);
addRow(grid, row++, "Connected", connectedValue);
addRow(grid, row, "Filetransfer (↑/↓)", filetransferValue);
return grid;
}
private JTabbedPane buildTabs() {
JTabbedPane tabs = new JTabbedPane();
tabs.setFont(Theme.UI_FONT);
tabs.setBackground(Theme.WINDOW_BG);
tabs.addTab("Total", totalTab);
tabs.addTab("Speech", speechTab);
tabs.addTab("Keep Alive", keepAliveTab);
tabs.addTab("Control", controlTab);
return tabs;
}
private JPanel buildButtons() {
JPanel bar = new JPanel();
bar.setLayout(new BoxLayout(bar, BoxLayout.X_AXIS));
bar.setBackground(Theme.WINDOW_BG);
bar.add(Box.createHorizontalGlue());
JButton close = new JButton("Close");
close.addActionListener(e -> dispose());
bar.add(close);
return bar;
}
private static JLabel value() {
JLabel l = new JLabel("");
l.setFont(Theme.UI_FONT);
l.setForeground(Theme.TREE_TEXT);
return l;
}
private static void addRow(JPanel grid, int row, String label, JLabel valueLabel) {
GridBagConstraints lc = new GridBagConstraints();
lc.gridx = 0;
lc.gridy = row;
lc.anchor = GridBagConstraints.WEST;
lc.insets = new Insets(2, 0, 2, 16);
JLabel key = new JLabel(label);
key.setFont(Theme.UI_FONT);
key.setForeground(new Color(0x5A6B7B));
grid.add(key, lc);
GridBagConstraints vc = new GridBagConstraints();
vc.gridx = 1;
vc.gridy = row;
vc.weightx = 1;
vc.anchor = GridBagConstraints.WEST;
vc.fill = GridBagConstraints.HORIZONTAL;
vc.insets = new Insets(2, 0, 2, 0);
grid.add(valueLabel, vc);
}
// ---- live update ----
private void refresh() {
if (!conn.isConnected()) {
timer.stop();
return;
}
if (!inFlight.compareAndSet(false, true)) return; // a previous poll is still running
conn.requestConnectionInfo(clientId, stats -> SwingUtilities.invokeLater(() -> {
inFlight.set(false);
apply(stats);
}));
}
private void apply(ConnectionStats s) {
if (!isDisplayable()) return;
addressValue.setText(s.self ? "This computer" : orDash(s.ip));
versionValue.setText(orDash(s.version));
platformValue.setText(orDash(s.platform));
pingValue.setText(formatPing(s.pingMs, s.pingDeviationMs));
idleValue.setText(formatDuration(s.idleTimeMs));
connectedValue.setText(formatDuration(s.connectedTimeMs));
filetransferValue.setText(formatRate(s.filetransferBandwidthSent)
+ " / " + formatRate(s.filetransferBandwidthReceived));
totalTab.update(totalSample(s));
speechTab.update(kindSample(s, ConnectionStats.Kind.SPEECH));
keepAliveTab.update(kindSample(s, ConnectionStats.Kind.KEEPALIVE));
controlTab.update(kindSample(s, ConnectionStats.Kind.CONTROL));
growToFit();
}
/**
* The window is first packed around placeholder text; once real (longer)
* values arrive it may need more room. Grow to fit, but never shrink, so the
* window doesn't jitter as counters tick up each second.
*/
private void growToFit() {
Dimension pref = getContentPane().getPreferredSize();
Dimension have = getContentPane().getSize();
if (pref.width > have.width || pref.height > have.height) {
pack();
}
}
private static Sample totalSample(ConnectionStats s) {
Sample sample = new Sample();
sample.packetLoss = s.packetLoss;
sample.packetsSent = s.packetsSentTotal;
sample.packetsReceived = s.packetsReceivedTotal;
sample.bytesSent = s.bytesSentTotal;
sample.bytesReceived = s.bytesReceivedTotal;
sample.bwSentSecond = s.bandwidthSentLastSecond;
sample.bwReceivedSecond = s.bandwidthReceivedLastSecond;
sample.bwSentMinute = s.bandwidthSentLastMinute;
sample.bwReceivedMinute = s.bandwidthReceivedLastMinute;
return sample;
}
private static Sample kindSample(ConnectionStats s, ConnectionStats.Kind kind) {
Sample sample = new Sample();
ConnectionStats.KindStats k = s.kind(kind);
if (k == null) return sample; // all fields stay unknown (-1)
sample.packetLoss = k.packetLoss;
sample.packetsSent = k.packetsSent;
sample.packetsReceived = k.packetsReceived;
sample.bytesSent = k.bytesSent;
sample.bytesReceived = k.bytesReceived;
sample.bwSentSecond = k.bandwidthSentLastSecond;
sample.bwReceivedSecond = k.bandwidthReceivedLastSecond;
sample.bwSentMinute = k.bandwidthSentLastMinute;
sample.bwReceivedMinute = k.bandwidthReceivedLastMinute;
return sample;
}
// ---- one category tab ----
/** Numeric snapshot fed to a {@link KindTab}; -1 means "unknown". */
private static final class Sample {
double packetLoss = -1;
long packetsSent = -1;
long packetsReceived = -1;
long bytesSent = -1;
long bytesReceived = -1;
long bwSentSecond = -1;
long bwReceivedSecond = -1;
long bwSentMinute = -1;
long bwReceivedMinute = -1;
}
private static final class KindTab extends JPanel {
private final JLabel packetLoss = value();
private final JLabel packets = value();
private final JLabel bytes = value();
private final JLabel bandwidthSecond = value();
private final JLabel bandwidthMinute = value();
KindTab() {
super(new GridBagLayout());
setBackground(Theme.WINDOW_BG);
setBorder(BorderFactory.createEmptyBorder(10, 12, 10, 12));
int row = 0;
addRow(this, row++, "Packet loss", packetLoss);
addRow(this, row++, "Packets (↑/↓)", packets);
addRow(this, row++, "Transferred (↑/↓)", bytes);
addRow(this, row++, "Bandwidth · last second (↑/↓)", bandwidthSecond);
addRow(this, row, "Bandwidth · last minute (↑/↓)", bandwidthMinute);
}
void update(Sample s) {
packetLoss.setText(formatLoss(s.packetLoss));
packets.setText(formatCount(s.packetsSent) + " / " + formatCount(s.packetsReceived));
bytes.setText(formatBytes(s.bytesSent) + " / " + formatBytes(s.bytesReceived));
bandwidthSecond.setText(formatRate(s.bwSentSecond) + " / " + formatRate(s.bwReceivedSecond));
bandwidthMinute.setText(formatRate(s.bwSentMinute) + " / " + formatRate(s.bwReceivedMinute));
}
}
// ---- formatting ----
private static final DecimalFormat GROUPED = new DecimalFormat("#,##0");
private static String orDash(String s) {
return (s == null || s.isEmpty()) ? "" : s;
}
private static String formatPing(double ms, double deviationMs) {
if (ms < 0) return "";
String base = Math.round(ms) + " ms";
if (deviationMs > 0) base += "" + Math.round(deviationMs) + " ms)";
return base;
}
private static String formatLoss(double fraction) {
if (fraction < 0) return "";
return new DecimalFormat("0.00").format(fraction * 100.0) + " %";
}
private static String formatCount(long n) {
return n < 0 ? "" : GROUPED.format(n);
}
private static String formatBytes(long bytes) {
if (bytes < 0) return "";
if (bytes < 1024) return bytes + " B";
double kib = bytes / 1024.0;
if (kib < 1024) return new DecimalFormat("0.0").format(kib) + " KiB";
double mib = kib / 1024.0;
if (mib < 1024) return new DecimalFormat("0.00").format(mib) + " MiB";
return new DecimalFormat("0.00").format(mib / 1024.0) + " GiB";
}
private static String formatRate(long bytesPerSecond) {
return bytesPerSecond < 0 ? "" : formatBytes(bytesPerSecond) + "/s";
}
private static String formatDuration(long ms) {
if (ms < 0) return "";
long totalSeconds = ms / 1000;
long days = totalSeconds / 86400;
long hours = (totalSeconds % 86400) / 3600;
long minutes = (totalSeconds % 3600) / 60;
long seconds = totalSeconds % 60;
StringBuilder sb = new StringBuilder();
if (days > 0) sb.append(days).append("d ");
if (days > 0 || hours > 0) sb.append(hours).append("h ");
if (days > 0 || hours > 0 || minutes > 0) sb.append(minutes).append("m ");
sb.append(seconds).append("s");
return sb.toString();
}
}

View File

@@ -0,0 +1,196 @@
package com.ts3client.ui;
import javax.swing.ImageIcon;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
/**
* Programmatically drawn vector icons (no external image assets), so the client
* is fully self-contained. All icons are rendered at 16&times;16 with a small
* cache.
*/
public final class Icons {
private static final int SZ = 16;
private Icons() {
}
private interface Painter {
void paint(Graphics2D g);
}
private static ImageIcon make(Painter p) {
BufferedImage img = new BufferedImage(SZ, SZ, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = img.createGraphics();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE);
p.paint(g);
g.dispose();
return new ImageIcon(img);
}
// ---- tree icons ----
public static ImageIcon server() {
return make(g -> {
g.setColor(new Color(0x2C6EA5));
g.fillRoundRect(2, 3, 12, 4, 2, 2);
g.fillRoundRect(2, 9, 12, 4, 2, 2);
g.setColor(new Color(0x9FD0F0));
g.fillOval(4, 4, 2, 2);
g.fillOval(4, 10, 2, 2);
});
}
public static ImageIcon channel() {
return channelPainted(new Color(0x3E7CB1), false);
}
public static ImageIcon channelLocked() {
return channelPainted(new Color(0x8A6D3B), true);
}
private static ImageIcon channelPainted(Color c, boolean lock) {
return make(g -> {
g.setColor(c);
g.setStroke(new BasicStroke(1.6f));
// simple speaker-cone glyph
g.fillRect(2, 6, 3, 4);
int[] xs = {5, 9, 9, 5};
int[] ys = {6, 3, 13, 10};
g.fillPolygon(xs, ys, 4);
g.setStroke(new BasicStroke(1.4f));
g.drawArc(9, 5, 4, 6, -60, 120);
if (lock) {
g.setColor(new Color(0xB8860B));
g.fillRoundRect(10, 9, 5, 5, 1, 1);
g.setColor(Color.WHITE);
g.fillRect(12, 10, 1, 2);
}
});
}
// ---- client status icons ----
public static ImageIcon clientIdle() {
return person(Theme.IDLE_CLIENT);
}
public static ImageIcon clientTalking() {
return person(Theme.TALKING);
}
public static ImageIcon clientAway() {
return person(Theme.AWAY);
}
private static ImageIcon person(Color c) {
return make(g -> {
g.setColor(c);
g.fillOval(5, 2, 6, 6); // head
g.fillRoundRect(3, 9, 10, 6, 4, 4); // shoulders
});
}
public static ImageIcon micMuted() {
return make(g -> {
g.setColor(Theme.MUTED);
g.fillRoundRect(6, 2, 4, 7, 2, 2);
g.setStroke(new BasicStroke(1.4f));
g.drawArc(4, 6, 8, 6, 200, 140);
g.drawLine(8, 12, 8, 14);
g.setStroke(new BasicStroke(2f));
g.drawLine(2, 2, 14, 14); // slash
});
}
public static ImageIcon speakerMuted() {
return make(g -> {
g.setColor(Theme.MUTED);
g.fillRect(2, 6, 3, 4);
int[] xs = {5, 9, 9, 5};
int[] ys = {6, 3, 13, 10};
g.fillPolygon(xs, ys, 4);
g.setStroke(new BasicStroke(2f));
g.drawLine(2, 2, 14, 14);
});
}
// ---- toolbar / action icons ----
public static ImageIcon connect() {
return make(g -> {
g.setColor(new Color(0x2E8B57));
g.setStroke(new BasicStroke(2f));
g.drawLine(3, 8, 8, 8);
g.fillRoundRect(8, 5, 5, 6, 2, 2);
g.drawLine(10, 3, 10, 5);
g.drawLine(12, 3, 12, 5);
});
}
public static ImageIcon disconnect() {
return make(g -> {
g.setColor(Theme.MUTED);
g.setStroke(new BasicStroke(2f));
g.drawLine(3, 8, 8, 8);
g.fillRoundRect(8, 5, 5, 6, 2, 2);
g.drawLine(2, 3, 6, 13);
});
}
public static ImageIcon mic() {
return make(g -> {
g.setColor(new Color(0x37474F));
g.fillRoundRect(6, 2, 4, 7, 2, 2);
g.setStroke(new BasicStroke(1.4f));
g.drawArc(4, 6, 8, 6, 200, 140);
g.drawLine(8, 12, 8, 14);
g.drawLine(6, 14, 10, 14);
});
}
public static ImageIcon speaker() {
return make(g -> {
g.setColor(new Color(0x37474F));
g.fillRect(2, 6, 3, 4);
int[] xs = {5, 9, 9, 5};
int[] ys = {6, 3, 13, 10};
g.fillPolygon(xs, ys, 4);
g.setStroke(new BasicStroke(1.4f));
g.drawArc(9, 5, 4, 6, -60, 120);
});
}
public static ImageIcon settings() {
return make(g -> {
g.setColor(new Color(0x37474F));
g.setStroke(new BasicStroke(2f));
g.drawOval(5, 5, 6, 6);
for (int a = 0; a < 360; a += 45) {
double r = Math.toRadians(a);
int x1 = (int) (8 + Math.cos(r) * 5);
int y1 = (int) (8 + Math.sin(r) * 5);
int x2 = (int) (8 + Math.cos(r) * 7);
int y2 = (int) (8 + Math.sin(r) * 7);
g.drawLine(x1, y1, x2, y2);
}
});
}
public static ImageIcon app() {
return make(g -> {
g.setColor(Theme.ACCENT);
g.fillRoundRect(1, 1, 14, 14, 4, 4);
g.setColor(Color.WHITE);
g.setStroke(new BasicStroke(1.6f));
g.drawArc(4, 5, 8, 8, 30, 120);
g.drawArc(2, 3, 12, 12, 30, 120);
g.fillOval(7, 9, 2, 2);
});
}
}

View File

@@ -0,0 +1,100 @@
package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.ServerModel;
import javax.swing.BorderFactory;
import javax.swing.JEditorPane;
import javax.swing.JScrollPane;
import java.util.List;
/**
* Read-only detail view for the currently selected channel or client, mirroring
* the TeamSpeak 3 info box: channel topic/description, or a client's server and
* channel groups, platform and version.
*/
public final class InfoPanel extends JScrollPane {
private final JEditorPane pane = new JEditorPane();
public InfoPanel() {
pane.setContentType("text/html");
pane.setEditable(false);
pane.setBackground(Theme.CHAT_BG);
pane.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
setViewportView(pane);
setBorder(BorderFactory.createLineBorder(new java.awt.Color(0xD0D0D0)));
clear();
}
public void clear() {
setHtml("<i style='color:#8a8a8a'>Select a channel or client to see details.</i>");
}
public void showChannel(ChannelNode ch) {
StringBuilder sb = new StringBuilder();
sb.append(heading(esc(ch.name)));
row(sb, "Type", ch.permanent ? "Permanent" : "Temporary");
if (ch.maxClients >= 0) row(sb, "Max clients", Integer.toString(ch.maxClients));
if (ch.hasPassword) row(sb, "Password", "protected");
if (!ch.topic.isEmpty()) row(sb, "Topic", esc(ch.topic));
sb.append("<hr>");
if (ch.description != null && !ch.description.isEmpty()) {
sb.append("<div>").append(multiline(ch.description)).append("</div>");
} else if (ch.descriptionLoaded) {
sb.append("<i style='color:#8a8a8a'>No description.</i>");
} else {
sb.append("<i style='color:#8a8a8a'>Loading description…</i>");
}
setHtml(sb.toString());
}
public void showClient(ClientEntry cl, ServerModel model) {
StringBuilder sb = new StringBuilder();
sb.append(heading(esc(cl.nickname) + (cl.self ? " <span style='color:#8a8a8a'>(you)</span>" : "")));
List<String> serverGroups = model.serverGroupNames(cl.serverGroupIds);
row(sb, "Server groups", serverGroups.isEmpty() ? "" : esc(String.join(", ", serverGroups)));
String channelGroup = model.channelGroupName(cl.channelGroupId);
row(sb, "Channel group", channelGroup != null ? esc(channelGroup) : "#" + cl.channelGroupId);
if (cl.talkPower != 0) row(sb, "Talk power", Integer.toString(cl.talkPower));
if (!cl.platform.isEmpty()) row(sb, "Platform", esc(cl.platform));
if (!cl.version.isEmpty()) row(sb, "Version", esc(cl.version));
if (cl.away) row(sb, "Status", "Away");
if (cl.inputMuted) row(sb, "Microphone", "muted");
if (cl.outputMuted) row(sb, "Speakers", "muted");
if (!cl.uniqueId.isEmpty()) row(sb, "Unique ID", esc(cl.uniqueId));
if (cl.description != null && !cl.description.isEmpty()) {
sb.append("<hr><div>").append(multiline(cl.description)).append("</div>");
}
setHtml(sb.toString());
}
private void setHtml(String body) {
pane.setText("<html><body style='font-family:sans-serif;font-size:11px;color:#202020'>"
+ body + "</body></html>");
pane.setCaretPosition(0);
}
private static String heading(String text) {
return "<div style='font-weight:bold;font-size:13px;margin-bottom:4px'>" + text + "</div>";
}
private static void row(StringBuilder sb, String label, String value) {
sb.append("<div style='margin:1px 0'><span style='color:#5a6b7b'>")
.append(label).append(":</span> ").append(value).append("</div>");
}
private static String multiline(String text) {
return esc(text).replace("\n", "<br>");
}
private static String esc(String s) {
if (s == null) return "";
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
}

View File

@@ -0,0 +1,69 @@
package com.ts3client.ui;
import javax.swing.JComponent;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
/**
* Horizontal audio level meter (dBFS) with an optional VAD threshold marker.
* The filled portion turns green once the level crosses the threshold, giving
* immediate visual feedback while tuning voice activation.
*/
public final class LevelMeter extends JComponent {
private static final double MIN_DB = -70.0;
private static final double MAX_DB = 0.0;
private volatile double levelDb = MIN_DB;
private volatile double thresholdDb = -45.0;
private volatile boolean showThreshold = true;
public LevelMeter() {
setPreferredSize(new Dimension(240, 18));
}
public void setLevel(double db) {
this.levelDb = db;
repaint();
}
public void setThreshold(double db) {
this.thresholdDb = db;
repaint();
}
public void setShowThreshold(boolean show) {
this.showThreshold = show;
repaint();
}
private int dbToX(double db, int w) {
double clamped = Math.max(MIN_DB, Math.min(MAX_DB, db));
return (int) ((clamped - MIN_DB) / (MAX_DB - MIN_DB) * w);
}
@Override
protected void paintComponent(Graphics g0) {
Graphics2D g = (Graphics2D) g0;
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
int w = getWidth();
int h = getHeight();
g.setColor(new Color(0x2B2B2B));
g.fillRoundRect(0, 0, w - 1, h - 1, 6, 6);
int level = dbToX(levelDb, w - 2);
boolean over = levelDb >= thresholdDb;
g.setColor(over ? Theme.TALKING : new Color(0x5A9BD4));
g.fillRoundRect(1, 1, Math.max(0, level), h - 3, 5, 5);
if (showThreshold) {
int tx = dbToX(thresholdDb, w - 2);
g.setColor(new Color(0xF0C419));
g.fillRect(tx, 1, 2, h - 3);
}
}
}

View File

@@ -0,0 +1,575 @@
package com.ts3client.ui;
import com.ts3client.audio.AudioBackend;
import com.ts3client.audio.desktop.JavaSoundAudioBackend;
import com.ts3client.config.Bookmark;
import com.ts3client.config.Bookmarks;
import com.ts3client.config.Settings;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.ConnectionListener;
import com.ts3client.net.TeamspeakConnection;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JCheckBoxMenuItem;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JMenu;
import javax.swing.JMenuBar;
import javax.swing.JMenuItem;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JSplitPane;
import javax.swing.JToggleButton;
import javax.swing.JToolBar;
import javax.swing.KeyStroke;
import javax.swing.SwingUtilities;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.KeyEventDispatcher;
import java.awt.KeyboardFocusManager;
import java.awt.event.KeyEvent;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* The main application window: toolbar, server tree, chat and status bar,
* wired to a {@link TeamspeakConnection}. Resembles the TeamSpeak 3 client layout.
*/
public final class MainFrame extends JFrame implements ConnectionListener, ServerTreePanel.Actions {
private final Settings settings;
private final Bookmarks bookmarks = Bookmarks.load();
private final AudioBackend audio = new JavaSoundAudioBackend();
private TeamspeakConnection conn;
private JMenu bookmarksMenu;
private JCheckBoxMenuItem awayItem;
private JCheckBoxMenuItem commanderItem;
private javax.swing.Timer statusTimer;
private final ServerTreePanel treePanel;
private final ChatPanel chatPanel;
private final InfoPanel infoPanel = new InfoPanel();
private Object currentSelection;
private final JLabel statusLabel = new JLabel("Not connected");
private final JLabel codecLabel = new JLabel();
private JButton connectButton;
private JButton disconnectButton;
private JToggleButton micButton;
private JToggleButton speakerButton;
private boolean pttPressed;
/** Guards {@link #shutdown()} so the window listener and JVM hook don't both run it. */
private final AtomicBoolean shuttingDown = new AtomicBoolean(false);
private final Thread shutdownHook = new Thread(this::shutdown, "ts3j-shutdown");
public MainFrame(Settings settings) {
super("TS3J — TeamSpeak 3 Java Client");
this.settings = settings;
setIconImage(Icons.app().getImage());
// We tear the connection down ourselves on close, so don't let Swing kill the JVM.
setDefaultCloseOperation(DO_NOTHING_ON_CLOSE);
addWindowListener(new WindowAdapter() {
@Override
public void windowClosing(WindowEvent e) {
shutdown();
System.exit(0);
}
});
// Catches Ctrl+C / SIGTERM so we still leave the server cleanly.
Runtime.getRuntime().addShutdownHook(shutdownHook);
setMinimumSize(new Dimension(720, 480));
this.conn = new TeamspeakConnection(settings, audio, this);
this.treePanel = new ServerTreePanel(conn.getModel(), this);
this.chatPanel = new ChatPanel();
chatPanel.setSendHandler(this::onSendChat);
setJMenuBar(buildMenuBar());
add(buildToolbar(), BorderLayout.NORTH);
JSplitPane leftColumn = new JSplitPane(JSplitPane.VERTICAL_SPLIT, treePanel, infoPanel);
leftColumn.setResizeWeight(0.68);
leftColumn.setContinuousLayout(true);
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, leftColumn, chatPanel);
split.setResizeWeight(0.55);
split.setDividerLocation(400);
split.setContinuousLayout(true);
add(split, BorderLayout.CENTER);
add(buildStatusBar(), BorderLayout.SOUTH);
codecLabel.setText(audio.description());
chatPanel.appendSystem("Welcome to the TS3J Swing client.");
chatPanel.appendSystem("Use Connections → Connect to join a server.");
installPushToTalk();
updateButtons(false);
statusTimer = new javax.swing.Timer(3000, e -> updateConnectionStatus());
statusTimer.start();
setSize(880, 560);
setLocationRelativeTo(null);
}
// ---- UI construction ----
private JMenuBar buildMenuBar() {
JMenuBar bar = new JMenuBar();
JMenu connections = new JMenu("Connections");
JMenuItem connect = new JMenuItem("Connect…");
connect.setAccelerator(KeyStroke.getKeyStroke("control S"));
connect.addActionListener(e -> showConnectDialog());
JMenuItem disconnect = new JMenuItem("Disconnect");
disconnect.addActionListener(e -> doDisconnect());
JMenuItem quit = new JMenuItem("Quit");
quit.addActionListener(e -> {
shutdown();
System.exit(0);
});
connections.add(connect);
connections.add(disconnect);
connections.addSeparator();
connections.add(quit);
bookmarksMenu = new JMenu("Bookmarks");
rebuildBookmarksMenu();
JMenu self = new JMenu("Self");
JMenuItem mute = new JMenuItem("Toggle microphone");
mute.addActionListener(e -> micButton.doClick());
JMenuItem deaf = new JMenuItem("Toggle speakers");
deaf.addActionListener(e -> speakerButton.doClick());
awayItem = new JCheckBoxMenuItem("Away");
awayItem.addActionListener(e -> toggleAway());
commanderItem = new JCheckBoxMenuItem("Channel Commander");
commanderItem.addActionListener(e -> conn.setChannelCommander(commanderItem.isSelected()));
JMenuItem rename = new JMenuItem("Change nickname…");
rename.addActionListener(e -> changeNickname());
self.add(mute);
self.add(deaf);
self.addSeparator();
self.add(awayItem);
self.add(commanderItem);
self.addSeparator();
self.add(rename);
JMenu tools = new JMenu("Tools");
JMenuItem options = new JMenuItem("Options…");
options.addActionListener(e -> showSettings());
tools.add(options);
JMenu help = new JMenu("Help");
JMenuItem about = new JMenuItem("About");
about.addActionListener(e -> showAbout());
help.add(about);
bar.add(connections);
bar.add(bookmarksMenu);
bar.add(self);
bar.add(tools);
bar.add(help);
return bar;
}
private void rebuildBookmarksMenu() {
bookmarksMenu.removeAll();
for (Bookmark b : bookmarks.all()) {
JMenuItem item = new JMenuItem(b.displayName());
item.addActionListener(e -> connectToBookmark(b));
bookmarksMenu.add(item);
}
if (!bookmarks.all().isEmpty()) bookmarksMenu.addSeparator();
JMenuItem addCurrent = new JMenuItem("Add current server…");
addCurrent.addActionListener(e -> addCurrentServerBookmark());
JMenuItem manage = new JMenuItem("Manage bookmarks…");
manage.addActionListener(e -> new BookmarksDialog(this, bookmarks,
this::connectToBookmark, this::rebuildBookmarksMenu).setVisible(true));
bookmarksMenu.add(addCurrent);
bookmarksMenu.add(manage);
}
private JToolBar buildToolbar() {
JToolBar tb = new JToolBar();
tb.setFloatable(false);
tb.setBackground(Theme.TOOLBAR_BG);
tb.setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6));
connectButton = new JButton(Icons.connect());
connectButton.setToolTipText("Connect to a server");
connectButton.addActionListener(e -> showConnectDialog());
disconnectButton = new JButton(Icons.disconnect());
disconnectButton.setToolTipText("Disconnect");
disconnectButton.addActionListener(e -> doDisconnect());
micButton = new JToggleButton(Icons.mic());
micButton.setToolTipText("Mute / unmute microphone");
micButton.addActionListener(e -> {
boolean muted = micButton.isSelected();
micButton.setIcon(muted ? Icons.micMuted() : Icons.mic());
conn.setMicMuted(muted);
chatPanel.appendSystem(muted ? "Microphone muted." : "Microphone active.");
});
speakerButton = new JToggleButton(Icons.speaker());
speakerButton.setToolTipText("Deafen / undeafen (mute speakers)");
speakerButton.addActionListener(e -> {
boolean deaf = speakerButton.isSelected();
speakerButton.setIcon(deaf ? Icons.speakerMuted() : Icons.speaker());
conn.setDeafened(deaf);
if (deaf) {
micButton.setSelected(true);
micButton.setIcon(Icons.micMuted());
}
chatPanel.appendSystem(deaf ? "Speakers muted (deafened)." : "Speakers active.");
});
JButton settingsButton = new JButton(Icons.settings());
settingsButton.setToolTipText("Options");
settingsButton.addActionListener(e -> showSettings());
tb.add(connectButton);
tb.add(disconnectButton);
tb.addSeparator();
tb.add(micButton);
tb.add(speakerButton);
tb.addSeparator();
tb.add(settingsButton);
return tb;
}
private JPanel buildStatusBar() {
JPanel bar = new JPanel(new BorderLayout());
bar.setBackground(Theme.STATUS_BG);
bar.setBorder(BorderFactory.createEmptyBorder(3, 8, 3, 8));
statusLabel.setFont(Theme.UI_FONT);
codecLabel.setFont(Theme.UI_FONT);
codecLabel.setForeground(Theme.CHAT_SYSTEM);
bar.add(statusLabel, BorderLayout.WEST);
bar.add(codecLabel, BorderLayout.EAST);
return bar;
}
// ---- push to talk ----
private void installPushToTalk() {
KeyboardFocusManager.getCurrentKeyboardFocusManager().addKeyEventDispatcher(new KeyEventDispatcher() {
@Override
public boolean dispatchKeyEvent(KeyEvent e) {
if (settings.inputMode != Settings.InputMode.PUSH_TO_TALK) return false;
if (conn == null || !conn.isConnected() || conn.getMicrophone() == null) return false;
if (e.getKeyCode() != settings.pushToTalkKey) return false;
if (e.getID() == KeyEvent.KEY_PRESSED && !pttPressed) {
pttPressed = true;
conn.getMicrophone().setPushToTalk(true);
} else if (e.getID() == KeyEvent.KEY_RELEASED) {
pttPressed = false;
conn.getMicrophone().setPushToTalk(false);
}
return false;
}
});
}
// ---- actions ----
private void showConnectDialog() {
if (conn.isConnected()) {
JOptionPane.showMessageDialog(this, "Already connected. Disconnect first.",
"Connect", JOptionPane.INFORMATION_MESSAGE);
return;
}
ConnectDialog dlg = new ConnectDialog(this, settings);
dlg.setVisible(true);
if (!dlg.isConfirmed()) return;
startConnection(dlg.getAddress(), dlg.getPort(), dlg.getNickname(), dlg.getPassword());
}
private void startConnection(String address, int port, String nickname, String password) {
if (conn.isConnected()) {
JOptionPane.showMessageDialog(this, "Already connected. Disconnect first.",
"Connect", JOptionPane.INFORMATION_MESSAGE);
return;
}
settings.lastAddress = address + ":" + port;
settings.nickname = nickname;
settings.serverPassword = password;
settings.save();
chatPanel.appendSystem("Connecting to " + address + ":" + port + "");
conn.connect(address, port, nickname, password);
}
private void connectToBookmark(Bookmark b) {
String nick = (b.nickname != null && !b.nickname.isBlank()) ? b.nickname : settings.nickname;
startConnection(b.address, b.port, nick, b.password);
}
private void addCurrentServerBookmark() {
String addr = settings.lastAddress;
int port = 9987;
int colon = addr.lastIndexOf(':');
if (colon > 0) {
try {
port = Integer.parseInt(addr.substring(colon + 1));
addr = addr.substring(0, colon);
} catch (NumberFormatException ignored) {
}
}
String label = JOptionPane.showInputDialog(this, "Bookmark label:", addr);
if (label == null) return;
bookmarks.add(new Bookmark(label.trim(), addr, port, settings.nickname, settings.serverPassword));
bookmarks.save();
rebuildBookmarksMenu();
}
private void toggleAway() {
boolean away = awayItem.isSelected();
String message = null;
if (away) {
message = JOptionPane.showInputDialog(this, "Away message (optional):", "");
if (message == null) { // cancelled
awayItem.setSelected(false);
return;
}
}
conn.setAway(away, message);
}
private void doDisconnect() {
if (conn.isConnected()) {
conn.disconnect();
}
}
/**
* Tear everything down before the process exits: disconnect from the server
* synchronously (so the "Leaving" notification actually reaches it) and stop
* background timers. Idempotent and safe to call from any thread — the window
* close handler, the Quit menu and the JVM shutdown hook may all invoke it.
*/
private void shutdown() {
if (!shuttingDown.compareAndSet(false, true)) return;
if (statusTimer != null) statusTimer.stop();
try {
Runtime.getRuntime().removeShutdownHook(shutdownHook);
} catch (IllegalStateException ignored) {
// Already shutting down (hook itself is running); nothing to remove.
}
if (conn.isConnected()) {
conn.disconnectBlocking("Leaving");
}
}
private void showSettings() {
SettingsDialog dlg = new SettingsDialog(this, settings,
conn.getMicrophone(), conn.getPlayback(), () -> {
});
dlg.setVisible(true);
}
private void changeNickname() {
String n = JOptionPane.showInputDialog(this, "New nickname:", settings.nickname);
if (n != null && !n.trim().isEmpty()) {
settings.nickname = n.trim();
settings.save();
if (conn.isConnected()) conn.setNickname(settings.nickname);
}
}
private void showAbout() {
JOptionPane.showMessageDialog(this,
"TS3J Swing Client\n\n" +
"An open-source TeamSpeak 3 desktop client built on the ts3j\n" +
"reverse-engineered protocol library, with native Opus voice,\n" +
"voice-activation detection and push-to-talk.\n\n" +
codecLabel.getText(),
"About TS3J", JOptionPane.INFORMATION_MESSAGE);
}
private void onSendChat(ChatPanel.Target target, String text) {
if (!conn.isConnected()) {
chatPanel.appendSystem("Not connected.");
return;
}
if (target == ChatPanel.Target.SERVER) {
conn.sendServerMessage(text);
} else {
conn.sendChannelMessage(text);
}
chatPanel.appendMessage(settings.nickname + " (you)", text);
}
private void updateButtons(boolean connected) {
connectButton.setEnabled(!connected);
disconnectButton.setEnabled(connected);
micButton.setEnabled(connected);
speakerButton.setEnabled(connected);
awayItem.setEnabled(connected);
commanderItem.setEnabled(connected);
chatPanel.setInputEnabled(connected);
}
private void updateConnectionStatus() {
if (!conn.isConnected()) return;
int users = conn.getModel().clientCount();
StringBuilder s = new StringBuilder("Connected to ")
.append(conn.getModel().getServerName())
.append(" | ").append(users).append(users == 1 ? " user" : " users");
double ping = conn.getPingMillis();
if (ping >= 0) s.append(" | ping ").append(Math.round(ping)).append(" ms");
statusLabel.setText(s.toString());
}
// ---- ServerTreePanel.Actions ----
@Override
public void joinChannel(int channelId) {
if (conn.isConnected()) conn.joinChannel(channelId, null);
}
@Override
public void openPrivateChat(ClientEntry client) {
String msg = JOptionPane.showInputDialog(this, "Message to " + client.nickname + ":");
if (msg != null && !msg.trim().isEmpty()) {
conn.sendPrivateMessage(client.id, msg.trim());
chatPanel.appendMessage("You → " + client.nickname, msg.trim());
}
}
@Override
public void pokeClient(ClientEntry client) {
String msg = JOptionPane.showInputDialog(this, "Poke message for " + client.nickname + ":", "Poke!");
if (msg != null) {
conn.poke(client.id, msg);
}
}
@Override
public void toggleClientMute(ClientEntry client) {
if (conn.getPlayback() == null) return;
boolean now = !conn.getPlayback().isClientMuted(client.id);
conn.getPlayback().setClientMuted(client.id, now);
chatPanel.appendSystem((now ? "Muted " : "Unmuted ") + client.nickname + ".");
}
@Override
public void showConnectionInfo(ClientEntry client) {
if (!conn.isConnected()) return;
new ConnectionInfoDialog(this, conn, client.id, client.nickname).setVisible(true);
}
@Override
public boolean isClientLocallyMuted(int clientId) {
return conn.getPlayback() != null && conn.getPlayback().isClientMuted(clientId);
}
@Override
public void onSelectionChanged(Object userObject) {
currentSelection = userObject;
renderInfo();
if (!conn.isConnected()) return;
if (userObject instanceof ChannelNode) {
ChannelNode ch = (ChannelNode) userObject;
if (!ch.descriptionLoaded) conn.requestChannelInfo(ch.id);
} else if (userObject instanceof ClientEntry) {
conn.requestClientInfo(((ClientEntry) userObject).id);
}
}
private void renderInfo() {
Object sel = currentSelection;
if (sel instanceof ChannelNode) {
infoPanel.showChannel((ChannelNode) sel);
} else if (sel instanceof ClientEntry) {
infoPanel.showClient((ClientEntry) sel, conn.getModel());
} else {
infoPanel.clear();
}
}
// ---- ConnectionListener (marshal to EDT) ----
@Override
public void onStatus(String status) {
SwingUtilities.invokeLater(() -> statusLabel.setText(status));
}
@Override
public void onConnected() {
SwingUtilities.invokeLater(() -> {
updateButtons(true);
treePanel.setSelfClientId(conn.getSelfClientId());
micButton.setSelected(false);
micButton.setIcon(Icons.mic());
speakerButton.setSelected(false);
speakerButton.setIcon(Icons.speaker());
awayItem.setSelected(false);
commanderItem.setSelected(false);
chatPanel.appendSystem("Connected.");
});
}
@Override
public void onDisconnected(String reason) {
SwingUtilities.invokeLater(() -> {
updateButtons(false);
conn.getModel().clear();
treePanel.showDisconnected();
currentSelection = null;
infoPanel.clear();
chatPanel.appendSystem("Disconnected" + (reason == null || reason.isEmpty() ? "." : ": " + reason));
});
}
@Override
public void onModelChanged() {
SwingUtilities.invokeLater(() -> {
treePanel.rebuild();
renderInfo();
});
}
@Override
public void onInfoUpdated() {
SwingUtilities.invokeLater(this::renderInfo);
}
@Override
public void onChat(ChatScope scope, int fromClientId, String fromName, String message) {
String prefix = scope == ChatScope.PRIVATE ? "[PM] " : scope == ChatScope.SERVER ? "[Server] " : "";
chatPanel.appendMessage(prefix + fromName, message);
}
@Override
public void onTalkStateChanged(int clientId, boolean talking) {
SwingUtilities.invokeLater(treePanel::refreshVisual);
}
@Override
public void onError(String message) {
SwingUtilities.invokeLater(() -> {
chatPanel.appendSystem("Error: " + message);
statusLabel.setText(message);
});
}
@Override
public void onPoke(String fromName, String message) {
SwingUtilities.invokeLater(() -> {
chatPanel.appendSystem("You were poked by " + fromName + ": " + message);
JOptionPane.showMessageDialog(this, fromName + " poked you:\n\n" + message,
"Poke", JOptionPane.INFORMATION_MESSAGE);
});
}
}

View File

@@ -0,0 +1,247 @@
package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.ServerModel;
import javax.swing.ImageIcon;
import javax.swing.JMenuItem;
import javax.swing.JPopupMenu;
import javax.swing.JScrollPane;
import javax.swing.JTree;
import javax.swing.SwingUtilities;
import javax.swing.tree.DefaultMutableTreeNode;
import javax.swing.tree.DefaultTreeModel;
import javax.swing.tree.DefaultTreeCellRenderer;
import javax.swing.tree.TreePath;
import java.awt.Component;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.List;
/**
* The server view: a tree of channels each containing its clients, styled to
* resemble the TeamSpeak 3 client. Talk state colours clients green live.
*/
public final class ServerTreePanel extends JScrollPane {
/** Actions the tree can request of the controller. */
public interface Actions {
void joinChannel(int channelId);
void openPrivateChat(ClientEntry client);
void pokeClient(ClientEntry client);
void toggleClientMute(ClientEntry client);
void showConnectionInfo(ClientEntry client);
boolean isClientLocallyMuted(int clientId);
/** A channel or client node was selected (or {@code null} when cleared). */
void onSelectionChanged(Object userObject);
}
private final JTree tree;
private final DefaultMutableTreeNode root = new DefaultMutableTreeNode();
private final DefaultTreeModel treeModel = new DefaultTreeModel(root);
private final ServerModel model;
private final Actions actions;
private int selfClientId = -1;
public ServerTreePanel(ServerModel model, Actions actions) {
this.model = model;
this.actions = actions;
root.setUserObject("Not connected");
this.tree = new JTree(treeModel);
tree.setRootVisible(true);
tree.setShowsRootHandles(true);
tree.setRowHeight(20);
tree.setBackground(Theme.TREE_BG);
tree.setFont(Theme.UI_FONT);
tree.setCellRenderer(new Renderer());
setViewportView(tree);
getViewport().setBackground(Theme.TREE_BG);
tree.addTreeSelectionListener(e -> {
TreePath path = tree.getSelectionPath();
Object obj = null;
if (path != null) {
obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
}
actions.onSelectionChanged(obj);
});
tree.addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) {
maybePopup(e);
}
@Override
public void mouseReleased(MouseEvent e) {
maybePopup(e);
}
@Override
public void mouseClicked(MouseEvent e) {
if (SwingUtilities.isLeftMouseButton(e) && e.getClickCount() == 2) {
Object obj = nodeAt(e);
if (obj instanceof ChannelNode && !Spacers.isSpacer(((ChannelNode) obj).name)) {
actions.joinChannel(((ChannelNode) obj).id);
}
}
}
});
}
public void setSelfClientId(int id) {
this.selfClientId = id;
}
private Object nodeAt(MouseEvent e) {
TreePath path = tree.getPathForLocation(e.getX(), e.getY());
if (path == null) return null;
DefaultMutableTreeNode n = (DefaultMutableTreeNode) path.getLastPathComponent();
return n.getUserObject();
}
private void maybePopup(MouseEvent e) {
if (!e.isPopupTrigger()) return;
TreePath path = tree.getPathForLocation(e.getX(), e.getY());
if (path == null) return;
tree.setSelectionPath(path);
Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
if (obj instanceof ClientEntry) {
showClientMenu((ClientEntry) obj, e);
} else if (obj instanceof ChannelNode) {
showChannelMenu((ChannelNode) obj, e);
}
}
private void showClientMenu(ClientEntry client, MouseEvent e) {
JPopupMenu menu = new JPopupMenu();
if (client.id != selfClientId) {
JMenuItem pm = new JMenuItem("Open text chat");
pm.addActionListener(a -> actions.openPrivateChat(client));
menu.add(pm);
JMenuItem poke = new JMenuItem("Poke");
poke.addActionListener(a -> actions.pokeClient(client));
menu.add(poke);
menu.addSeparator();
boolean muted = actions.isClientLocallyMuted(client.id);
JMenuItem mute = new JMenuItem(muted ? "Unmute client" : "Mute client");
mute.addActionListener(a -> actions.toggleClientMute(client));
menu.add(mute);
} else {
JMenuItem self = new JMenuItem("This is you");
self.setEnabled(false);
menu.add(self);
}
menu.addSeparator();
JMenuItem info = new JMenuItem("Connection Info");
info.addActionListener(a -> actions.showConnectionInfo(client));
menu.add(info);
menu.show(tree, e.getX(), e.getY());
}
private void showChannelMenu(ChannelNode channel, MouseEvent e) {
if (Spacers.isSpacer(channel.name)) return; // spacers aren't interactive
JPopupMenu menu = new JPopupMenu();
JMenuItem join = new JMenuItem("Join channel");
join.addActionListener(a -> actions.joinChannel(channel.id));
menu.add(join);
menu.show(tree, e.getX(), e.getY());
}
/** Rebuilds the tree from the model, preserving full expansion. */
public void rebuild() {
root.setUserObject(model.getServerName());
root.removeAllChildren();
List<ChannelNode> roots = model.buildTree();
for (ChannelNode c : roots) {
root.add(buildChannel(c));
}
treeModel.reload();
for (int i = 0; i < tree.getRowCount(); i++) {
tree.expandRow(i);
}
}
private DefaultMutableTreeNode buildChannel(ChannelNode c) {
DefaultMutableTreeNode node = new DefaultMutableTreeNode(c);
for (ClientEntry client : c.clients) {
node.add(new DefaultMutableTreeNode(client));
}
for (ChannelNode child : c.children) {
node.add(buildChannel(child));
}
return node;
}
/** Clears the tree back to the disconnected placeholder state. */
public void showDisconnected() {
root.setUserObject("Not connected");
root.removeAllChildren();
treeModel.reload();
}
/** Repaint only (e.g. talk-state changes) without rebuilding structure. */
public void refreshVisual() {
tree.repaint();
}
private final class Renderer extends DefaultTreeCellRenderer {
@Override
public Component getTreeCellRendererComponent(JTree tree, Object value, boolean sel,
boolean expanded, boolean leaf, int row,
boolean hasFocus) {
super.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus);
setBackgroundNonSelectionColor(Theme.TREE_BG);
setBackgroundSelectionColor(Theme.TREE_SELECTION);
setBorderSelectionColor(Theme.TREE_SELECTION);
Object obj = ((DefaultMutableTreeNode) value).getUserObject();
if (obj instanceof ChannelNode) {
ChannelNode c = (ChannelNode) obj;
Spacers.Spacer spacer = Spacers.parse(c.name);
if (spacer != null) {
setText(Spacers.render(spacer, 40));
setIcon(null);
setForeground(Theme.IDLE_CLIENT);
setFont(Theme.UI_FONT);
} else {
setText(c.name);
setIcon(c.hasPassword ? Icons.channelLocked() : Icons.channel());
setForeground(Theme.CHANNEL_TEXT);
setFont(Theme.UI_BOLD);
}
} else if (obj instanceof ClientEntry) {
ClientEntry cl = (ClientEntry) obj;
String primaryGroup = model.primaryServerGroupName(cl.serverGroupIds);
String label = cl.nickname;
if (primaryGroup != null) label += " [" + primaryGroup + "]";
setText(label);
setIcon(iconFor(cl));
setForeground(cl.talking ? Theme.TALKING : Theme.TREE_TEXT);
setFont(cl.talking ? Theme.UI_BOLD : Theme.UI_FONT);
} else {
// root / server
setText(String.valueOf(obj));
setIcon(Icons.server());
setForeground(Theme.SERVER_TEXT);
setFont(Theme.UI_BOLD);
}
return this;
}
private ImageIcon iconFor(ClientEntry cl) {
if (cl.outputMuted) return Icons.speakerMuted();
if (cl.inputMuted) return Icons.micMuted();
if (cl.away) return Icons.clientAway();
if (cl.talking) return Icons.clientTalking();
return Icons.clientIdle();
}
}
}

View File

@@ -0,0 +1,587 @@
package com.ts3client.ui;
import com.ts3client.audio.OpusParameters;
import com.ts3client.audio.VoiceInput;
import com.ts3client.audio.VoiceOutput;
import com.ts3client.audio.desktop.AudioDevices;
import com.ts3client.config.Settings;
import javax.sound.sampled.TargetDataLine;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.ButtonGroup;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JComboBox;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JRadioButton;
import javax.swing.JSlider;
import javax.swing.JTabbedPane;
import javax.swing.SwingUtilities;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.Frame;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import java.util.List;
/**
* Options dialog: audio device selection plus voice-activation / push-to-talk
* tuning with a live input meter. Changes are applied to the running audio
* subsystem immediately and persisted to {@link Settings} on OK.
*/
public final class SettingsDialog extends JDialog {
private final Settings settings;
private final VoiceInput liveMic;
private final VoiceOutput livePlayback;
private final Runnable onApply;
private JComboBox<String> inputCombo;
private JComboBox<String> outputCombo;
private JSlider inputGain;
private JSlider outputVol;
private JCheckBox denoiseCheck;
private JSlider denoiseLevel;
private JCheckBox typingCheck;
private JCheckBox agcCheck;
private JRadioButton vadRadio;
private JRadioButton pttRadio;
private JRadioButton contRadio;
private JComboBox<String> vadModeCombo;
private JSlider thresholdSlider;
private JSlider speechSlider;
private JCheckBox vadOverPttCheck;
private JLabel thresholdLabel;
private JLabel speechLabel;
private LevelMeter meter;
private JButton pttKeyButton;
private int pttKey;
private JSlider bitrateSlider;
private JLabel bitrateLabel;
private JSlider complexitySlider;
private JCheckBox vbrCheck;
private JCheckBox fecCheck;
private JCheckBox musicCheck;
private volatile boolean meterRunning;
private Thread meterThread;
public SettingsDialog(Frame owner, Settings settings,
VoiceInput liveMic, VoiceOutput livePlayback,
Runnable onApply) {
super(owner, "Options", true);
this.settings = settings;
this.liveMic = liveMic;
this.livePlayback = livePlayback;
this.onApply = onApply;
this.pttKey = settings.pushToTalkKey;
JTabbedPane tabs = new JTabbedPane();
tabs.addTab("Playback / Capture", scrollable(buildDevicesTab()));
tabs.addTab("Voice Activation", scrollable(buildVoiceTab()));
JPanel buttons = new JPanel(new BorderLayout());
JPanel right = new JPanel();
JButton ok = new JButton("OK");
JButton cancel = new JButton("Cancel");
ok.addActionListener(e -> {
apply();
close();
});
cancel.addActionListener(e -> close());
right.add(ok);
right.add(cancel);
buttons.add(right, BorderLayout.EAST);
getContentPane().setLayout(new BorderLayout());
getContentPane().add(tabs, BorderLayout.CENTER);
getContentPane().add(buttons, BorderLayout.SOUTH);
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
addWindowListener(new java.awt.event.WindowAdapter() {
@Override
public void windowClosed(java.awt.event.WindowEvent e) {
stopMeter();
}
});
pack();
setSize(new Dimension(480, 540));
setLocationRelativeTo(owner);
startMeter();
}
private JPanel buildDevicesTab() {
JPanel p = new JPanel(new GridBagLayout());
p.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
GridBagConstraints c = gbc();
List<String> ins = AudioDevices.inputDeviceNames();
List<String> outs = AudioDevices.outputDeviceNames();
ins.add(0, "(System default)");
outs.add(0, "(System default)");
inputCombo = new JComboBox<>(ins.toArray(new String[0]));
outputCombo = new JComboBox<>(outs.toArray(new String[0]));
selectOrDefault(inputCombo, settings.inputDevice);
selectOrDefault(outputCombo, settings.outputDevice);
int row = 0;
addRow(p, c, row++, new JLabel("Capture device (microphone):"), inputCombo);
addRow(p, c, row++, new JLabel("Playback device (speakers):"), outputCombo);
inputGain = new JSlider(0, 200, (int) Math.round(settings.inputVolume * 100));
outputVol = new JSlider(0, 200, (int) Math.round(settings.outputVolume * 100));
addRow(p, c, row++, new JLabel("Microphone gain:"), inputGain);
addRow(p, c, row++, new JLabel("Playback volume:"), outputVol);
outputVol.addChangeListener(e -> {
if (livePlayback != null) livePlayback.setMasterVolume(outputVol.getValue() / 100.0);
});
inputGain.addChangeListener(e -> {
if (liveMic != null) liveMic.setInputGain(inputGain.getValue() / 100.0);
});
inputCombo.addActionListener(e -> restartMeter());
c.gridx = 0;
c.gridy = row++;
c.gridwidth = 2;
c.insets = new Insets(14, 4, 2, 4);
p.add(new JLabel("Noise reduction"), c);
c.insets = new Insets(4, 4, 4, 4);
c.gridwidth = 1;
denoiseCheck = new JCheckBox("Remove background noise", settings.denoise);
denoiseCheck.setToolTipText("Attempt to filter out background noises.");
denoiseLevel = new JSlider(0, 100, (int) Math.round(settings.denoiserLevel * 100));
denoiseLevel.setToolTipText("Higher = more aggressive noise removal.");
typingCheck = new JCheckBox("Typing attenuation", settings.typingAttenuation);
typingCheck.setToolTipText("<html><b>Typing attenuation</b> tries to detect and "
+ "reduce the sounds made by typing.</html>");
agcCheck = new JCheckBox("Automatic gain control (AGC)", settings.agc);
agcCheck.setToolTipText("<html><b>Automatic gain control</b> normalises your "
+ "microphone loudness to a target level, boosting quiet mics and taming "
+ "loud ones.</html>");
c.gridx = 0;
c.gridy = row++;
c.gridwidth = 2;
p.add(denoiseCheck, c);
c.gridwidth = 1;
addRow(p, c, row++, new JLabel("Noise removal level:"), denoiseLevel);
c.gridx = 0;
c.gridy = row++;
c.gridwidth = 2;
p.add(typingCheck, c);
c.gridy = row++;
p.add(agcCheck, c);
c.gridwidth = 1;
Runnable syncNoise = () -> {
denoiseLevel.setEnabled(denoiseCheck.isSelected());
if (liveMic != null) {
liveMic.setNoiseSuppression(denoiseCheck.isSelected());
liveMic.setDenoiserLevel(denoiseLevel.getValue() / 100.0);
liveMic.setTypingAttenuation(typingCheck.isSelected());
liveMic.setAgc(agcCheck.isSelected());
}
};
denoiseCheck.addActionListener(e -> syncNoise.run());
typingCheck.addActionListener(e -> syncNoise.run());
agcCheck.addActionListener(e -> syncNoise.run());
denoiseLevel.addChangeListener(e -> {
if (liveMic != null) liveMic.setDenoiserLevel(denoiseLevel.getValue() / 100.0);
});
syncNoise.run();
// filler
c.gridx = 0;
c.gridy = row;
c.weighty = 1;
p.add(Box.createGlue(), c);
return p;
}
private JPanel buildVoiceTab() {
JPanel p = new JPanel(new GridBagLayout());
p.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
GridBagConstraints c = gbc();
vadRadio = new JRadioButton("Voice Activation Detection");
pttRadio = new JRadioButton("Push-To-Talk");
contRadio = new JRadioButton("Continuous");
ButtonGroup group = new ButtonGroup();
group.add(vadRadio);
group.add(pttRadio);
group.add(contRadio);
switch (settings.inputMode) {
case PUSH_TO_TALK:
pttRadio.setSelected(true);
break;
case CONTINUOUS:
contRadio.setSelected(true);
break;
default:
vadRadio.setSelected(true);
}
int row = 0;
c.gridx = 0;
c.gridy = row++;
c.gridwidth = 2;
p.add(vadRadio, c);
c.gridy = row++;
p.add(pttRadio, c);
c.gridy = row++;
p.add(contRadio, c);
c.gridwidth = 1;
meter = new LevelMeter();
meter.setThreshold(settings.vadThresholdDb);
c.gridx = 0;
c.gridy = row;
c.gridwidth = 2;
c.insets = new Insets(10, 4, 2, 4);
p.add(new JLabel("Input level (speak to test):"), c);
c.gridy = ++row;
p.add(meter, c);
c.insets = new Insets(4, 4, 4, 4);
c.gridwidth = 1;
row++;
vadModeCombo = new JComboBox<>(new String[]{"Automatic", "Volume Gate", "Hybrid"});
vadModeCombo.setSelectedIndex(vadModeIndex(settings.vadMode));
vadModeCombo.setToolTipText("<html><b>Automatic</b>: intelligent speech detection.<br>"
+ "<b>Volume Gate</b>: transmit when loud enough.<br>"
+ "<b>Hybrid</b>: loud enough <i>and</i> detected as speech.</html>");
addRow(p, c, row++, new JLabel("Detection:"), vadModeCombo);
thresholdSlider = new JSlider(-70, 0, (int) Math.round(settings.vadThresholdDb));
thresholdLabel = new JLabel(Math.round(settings.vadThresholdDb) + " dB");
thresholdSlider.addChangeListener(e -> {
meter.setThreshold(thresholdSlider.getValue());
thresholdLabel.setText(thresholdSlider.getValue() + " dB");
if (liveMic != null) liveMic.setThresholdDb(thresholdSlider.getValue());
});
addRow(p, c, row++, new JLabel("Volume gate:"), sliderWithLabel(thresholdSlider, thresholdLabel));
speechSlider = new JSlider(0, 100, (int) Math.round(settings.speechThreshold * 100));
speechLabel = new JLabel(Math.round(settings.speechThreshold * 100) + "%");
speechSlider.addChangeListener(e -> {
speechLabel.setText(speechSlider.getValue() + "%");
if (liveMic != null) liveMic.setSpeechThreshold(speechSlider.getValue() / 100.0);
});
addRow(p, c, row++, new JLabel("Speech threshold:"), sliderWithLabel(speechSlider, speechLabel));
pttKeyButton = new JButton(keyName(pttKey));
pttKeyButton.addActionListener(e -> capturePttKey());
addRow(p, c, row++, new JLabel("Push-to-talk key:"), pttKeyButton);
vadOverPttCheck = new JCheckBox("Also detect voice while push-to-talk", settings.vadOverPtt);
c.gridx = 0;
c.gridy = row++;
c.gridwidth = 2;
p.add(vadOverPttCheck, c);
c.gridwidth = 1;
bitrateSlider = new JSlider(8, 128, settings.bitrate / 1000);
bitrateLabel = new JLabel(settings.bitrate / 1000 + " kbit/s");
bitrateSlider.addChangeListener(e -> {
bitrateLabel.setText(bitrateSlider.getValue() + " kbit/s");
pushOpusLive();
});
JPanel brPanel = new JPanel(new BorderLayout(6, 0));
brPanel.add(bitrateSlider, BorderLayout.CENTER);
brPanel.add(bitrateLabel, BorderLayout.EAST);
addRow(p, c, row++, new JLabel("Opus bitrate:"), brPanel);
complexitySlider = new JSlider(0, 10, settings.complexity);
complexitySlider.addChangeListener(e -> pushOpusLive());
addRow(p, c, row++, new JLabel("Opus complexity:"), complexitySlider);
vbrCheck = new JCheckBox("Variable bitrate (VBR)", settings.vbr);
fecCheck = new JCheckBox("Forward error correction (FEC)", settings.fec);
musicCheck = new JCheckBox("Music codec (higher fidelity)", settings.music);
vbrCheck.addActionListener(e -> pushOpusLive());
fecCheck.addActionListener(e -> pushOpusLive());
musicCheck.addActionListener(e -> pushOpusLive());
c.gridx = 0;
c.gridy = row++;
c.gridwidth = 2;
p.add(vbrCheck, c);
c.gridy = row++;
p.add(fecCheck, c);
c.gridy = row++;
p.add(musicCheck, c);
c.gridwidth = 1;
Runnable syncEnabled = () -> {
boolean vad = vadRadio.isSelected();
boolean ptt = pttRadio.isSelected();
boolean vadContext = vad || (ptt && vadOverPttCheck.isSelected());
Settings.VadMode vm = currentVadMode();
boolean usesGate = vm != Settings.VadMode.AUTOMATIC;
boolean usesSpeech = vm != Settings.VadMode.VOLUME_GATE;
vadModeCombo.setEnabled(vadContext);
thresholdSlider.setEnabled(vadContext && usesGate);
speechSlider.setEnabled(vadContext && usesSpeech);
pttKeyButton.setEnabled(ptt);
vadOverPttCheck.setEnabled(ptt);
meter.setShowThreshold(vadContext && usesGate);
if (liveMic != null) {
liveMic.setMode(ptt ? Settings.InputMode.PUSH_TO_TALK
: vad ? Settings.InputMode.VOICE_ACTIVATION
: Settings.InputMode.CONTINUOUS);
liveMic.setVadMode(vm);
liveMic.setVadOverPtt(vadOverPttCheck.isSelected());
}
};
vadRadio.addActionListener(e -> syncEnabled.run());
pttRadio.addActionListener(e -> syncEnabled.run());
contRadio.addActionListener(e -> syncEnabled.run());
vadModeCombo.addActionListener(e -> syncEnabled.run());
vadOverPttCheck.addActionListener(e -> syncEnabled.run());
syncEnabled.run();
c.gridx = 0;
c.gridy = row;
c.weighty = 1;
p.add(Box.createGlue(), c);
return p;
}
private static int vadModeIndex(Settings.VadMode m) {
switch (m) {
case AUTOMATIC:
return 0;
case VOLUME_GATE:
return 1;
default:
return 2;
}
}
private Settings.VadMode currentVadMode() {
switch (vadModeCombo.getSelectedIndex()) {
case 0:
return Settings.VadMode.AUTOMATIC;
case 1:
return Settings.VadMode.VOLUME_GATE;
default:
return Settings.VadMode.HYBRID;
}
}
private static javax.swing.JScrollPane scrollable(JPanel content) {
javax.swing.JScrollPane sp = new javax.swing.JScrollPane(content,
javax.swing.ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
javax.swing.ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
sp.setBorder(null);
sp.getVerticalScrollBar().setUnitIncrement(16);
return sp;
}
private static JPanel sliderWithLabel(JSlider slider, JLabel valueLabel) {
JPanel panel = new JPanel(new BorderLayout(6, 0));
panel.add(slider, BorderLayout.CENTER);
valueLabel.setPreferredSize(new Dimension(48, valueLabel.getPreferredSize().height));
panel.add(valueLabel, BorderLayout.EAST);
return panel;
}
private OpusParameters currentOpusParameters() {
return new OpusParameters(
bitrateSlider.getValue() * 1000,
complexitySlider.getValue(),
vbrCheck.isSelected(),
fecCheck.isSelected(),
settings.packetLoss,
musicCheck.isSelected());
}
/** Applies the current Opus controls to the running encoder immediately. */
private void pushOpusLive() {
if (liveMic != null) liveMic.setOpusParameters(currentOpusParameters());
}
private void capturePttKey() {
pttKeyButton.setText("Press a key…");
pttKeyButton.requestFocusInWindow();
KeyAdapter ka = new KeyAdapter() {
@Override
public void keyPressed(KeyEvent e) {
pttKey = e.getKeyCode();
pttKeyButton.setText(keyName(pttKey));
pttKeyButton.removeKeyListener(this);
}
};
pttKeyButton.addKeyListener(ka);
}
private static String keyName(int code) {
String t = KeyEvent.getKeyText(code);
return (t == null || t.isEmpty()) ? ("Key " + code) : t;
}
private void apply() {
settings.inputDevice = comboValue(inputCombo);
settings.outputDevice = comboValue(outputCombo);
settings.inputVolume = inputGain.getValue() / 100.0;
settings.outputVolume = outputVol.getValue() / 100.0;
settings.denoise = denoiseCheck.isSelected();
settings.denoiserLevel = denoiseLevel.getValue() / 100.0;
settings.typingAttenuation = typingCheck.isSelected();
settings.agc = agcCheck.isSelected();
settings.inputMode = pttRadio.isSelected() ? Settings.InputMode.PUSH_TO_TALK
: contRadio.isSelected() ? Settings.InputMode.CONTINUOUS
: Settings.InputMode.VOICE_ACTIVATION;
settings.vadMode = currentVadMode();
settings.vadThresholdDb = thresholdSlider.getValue();
settings.speechThreshold = speechSlider.getValue() / 100.0;
settings.vadOverPtt = vadOverPttCheck.isSelected();
settings.pushToTalkKey = pttKey;
settings.bitrate = bitrateSlider.getValue() * 1000;
settings.complexity = complexitySlider.getValue();
settings.vbr = vbrCheck.isSelected();
settings.fec = fecCheck.isSelected();
settings.music = musicCheck.isSelected();
settings.save();
if (liveMic != null) {
liveMic.setMode(settings.inputMode);
liveMic.setVadMode(settings.vadMode);
liveMic.setThresholdDb(settings.vadThresholdDb);
liveMic.setSpeechThreshold(settings.speechThreshold);
liveMic.setVadOverPtt(settings.vadOverPtt);
liveMic.setInputGain(settings.inputVolume);
liveMic.setNoiseSuppression(settings.denoise);
liveMic.setDenoiserLevel(settings.denoiserLevel);
liveMic.setTypingAttenuation(settings.typingAttenuation);
liveMic.setAgc(settings.agc);
liveMic.setOpusParameters(OpusParameters.from(settings));
}
if (livePlayback != null) {
livePlayback.setMasterVolume(settings.outputVolume);
livePlayback.setOutputDevice(settings.outputDevice);
}
if (onApply != null) onApply.run();
}
private void close() {
stopMeter();
dispose();
}
// ---- live meter ----
private void startMeter() {
meterRunning = true;
meterThread = new Thread(this::meterLoop, "settings-meter");
meterThread.setDaemon(true);
meterThread.start();
}
private void restartMeter() {
stopMeter();
startMeter();
}
private void stopMeter() {
meterRunning = false;
if (meterThread != null) {
meterThread.interrupt();
meterThread = null;
}
}
private void meterLoop() {
String device = comboValue(inputCombo);
TargetDataLine line = null;
try {
line = AudioDevices.openCapture(device);
line.start();
int frame = AudioDevices.FRAME_SIZE;
byte[] buf = new byte[frame * 2];
double gain = (inputGain != null ? inputGain.getValue() / 100.0 : 1.0);
while (meterRunning) {
int read = 0;
while (read < buf.length) {
int n = line.read(buf, read, buf.length - read);
if (n <= 0) break;
read += n;
}
if (read < buf.length) break;
double sumSq = 0;
for (int i = 0; i < frame; i++) {
short s = (short) ((buf[2 * i + 1] << 8) | (buf[2 * i] & 0xFF));
double f = s / 32768.0 * gain;
sumSq += f * f;
}
double rms = Math.sqrt(sumSq / frame);
double db = rms <= 1e-9 ? -100 : 20 * Math.log10(rms);
final double fdb = db;
if (meter != null) SwingUtilities.invokeLater(() -> meter.setLevel(fdb));
}
} catch (Exception ignored) {
} finally {
if (line != null) {
try {
line.stop();
line.close();
} catch (Exception ignored) {
}
}
}
}
// ---- small helpers ----
private static GridBagConstraints gbc() {
GridBagConstraints c = new GridBagConstraints();
c.insets = new Insets(4, 4, 4, 4);
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
return c;
}
private static void addRow(JPanel p, GridBagConstraints c, int row, JLabel label, java.awt.Component field) {
c.gridx = 0;
c.gridy = row;
c.weightx = 0;
c.gridwidth = 1;
p.add(label, c);
c.gridx = 1;
c.weightx = 1;
p.add(field, c);
}
private static void selectOrDefault(JComboBox<String> combo, String value) {
if (value == null || value.isEmpty()) {
combo.setSelectedIndex(0);
return;
}
for (int i = 0; i < combo.getItemCount(); i++) {
if (value.equals(combo.getItemAt(i))) {
combo.setSelectedIndex(i);
return;
}
}
combo.setSelectedIndex(0);
}
private static String comboValue(JComboBox<String> combo) {
int idx = combo.getSelectedIndex();
if (idx <= 0) return "";
Object v = combo.getSelectedItem();
return v == null ? "" : v.toString();
}
}

View File

@@ -0,0 +1,63 @@
package com.ts3client.ui;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Parses TeamSpeak 3 "spacer" channel names — cosmetic root channels used as
* separators, e.g. {@code [spacer0]---}, {@code [*spacer1]=}, {@code [cspacer]Rules}.
* The tag selects alignment ({@code l}/{@code c}/{@code r}) or fill ({@code *}).
*/
public final class Spacers {
/** Result of parsing a spacer name. */
public static final class Spacer {
public final char align; // 'l', 'c', 'r', or '*' (repeat/fill)
public final String caption;
Spacer(char align, String caption) {
this.align = align;
this.caption = caption;
}
}
private static final Pattern PATTERN =
Pattern.compile("^\\[(\\*|[lcr])?spacer[^\\]]*\\](.*)$");
private Spacers() {
}
/** Returns spacer info if {@code channelName} is a spacer, else {@code null}. */
public static Spacer parse(String channelName) {
if (channelName == null) return null;
Matcher m = PATTERN.matcher(channelName);
if (!m.matches()) return null;
String tag = m.group(1);
char align = (tag == null || tag.isEmpty()) ? 'l' : tag.charAt(0);
return new Spacer(align, m.group(2));
}
public static boolean isSpacer(String channelName) {
return parse(channelName) != null;
}
/** Builds the visible label for a spacer at roughly the given character width. */
public static String render(Spacer s, int width) {
String caption = s.caption == null ? "" : s.caption;
if (s.align == '*') {
if (caption.isEmpty()) return "";
StringBuilder sb = new StringBuilder();
while (sb.length() < width) sb.append(caption);
return sb.substring(0, Math.max(caption.length(), Math.min(sb.length(), width)));
}
if (s.align == 'c') {
int pad = Math.max(0, (width - caption.length()) / 2);
return " ".repeat(pad) + caption;
}
if (s.align == 'r') {
int pad = Math.max(0, width - caption.length());
return " ".repeat(pad) + caption;
}
return caption;
}
}

View File

@@ -0,0 +1,35 @@
package com.ts3client.ui;
import java.awt.Color;
import java.awt.Font;
/** Central palette + fonts approximating the light TeamSpeak 3 look. */
public final class Theme {
public static final Color WINDOW_BG = new Color(0xF0F0F0);
public static final Color TREE_BG = new Color(0xFFFFFF);
public static final Color TREE_SELECTION = new Color(0xCFE3FB);
public static final Color TREE_TEXT = new Color(0x1E1E1E);
public static final Color CHANNEL_TEXT = new Color(0x21486B);
public static final Color SERVER_TEXT = new Color(0x123456);
public static final Color TALKING = new Color(0x33B35A);
public static final Color IDLE_CLIENT = new Color(0x6E7B87);
public static final Color AWAY = new Color(0xC98A1B);
public static final Color MUTED = new Color(0xC0392B);
public static final Color TOOLBAR_BG = new Color(0xE6E9ED);
public static final Color STATUS_BG = new Color(0xE6E9ED);
public static final Color ACCENT = new Color(0x2C7BE5);
public static final Color CHAT_BG = new Color(0xFAFAFA);
public static final Color CHAT_SYSTEM = new Color(0x8A8A8A);
public static final Color CHAT_NAME = new Color(0x2C7BE5);
public static final Color CHAT_TEXT = new Color(0x202020);
public static final Font UI_FONT = new Font("SansSerif", Font.PLAIN, 12);
public static final Font UI_BOLD = new Font("SansSerif", Font.BOLD, 12);
private Theme() {
}
}