From 0f76258a05f06a7c541d25b9a2ce397871b32327 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Wed, 12 Aug 2026 22:01:17 +0000 Subject: [PATCH] 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 --- .gitignore | 36 + .gitmodules | 3 + ts3-client/README.md | 154 ++++ ts3-client/core/pom.xml | 23 + .../com/ts3client/audio/AudioBackend.java | 17 + .../com/ts3client/audio/AudioEnhancer.java | 238 +++++ .../ts3client/audio/AutomaticGainControl.java | 66 ++ .../main/java/com/ts3client/audio/Fft.java | 68 ++ .../com/ts3client/audio/HighPassFilter.java | 43 + .../com/ts3client/audio/NoiseSuppressor.java | 112 +++ .../com/ts3client/audio/OpusParameters.java | 37 + .../com/ts3client/audio/SpeechDetector.java | 112 +++ .../com/ts3client/audio/TypingAttenuator.java | 78 ++ .../java/com/ts3client/audio/VoiceInput.java | 58 ++ .../java/com/ts3client/audio/VoiceOutput.java | 38 + .../java/com/ts3client/config/Bookmark.java | 32 + .../java/com/ts3client/config/Bookmarks.java | 92 ++ .../java/com/ts3client/config/Settings.java | 220 +++++ .../java/com/ts3client/net/ChannelNode.java | 27 + .../java/com/ts3client/net/ClientEntry.java | 36 + .../com/ts3client/net/ConnectionListener.java | 33 + .../com/ts3client/net/ConnectionStats.java | 94 ++ .../java/com/ts3client/net/ServerModel.java | 158 ++++ .../ts3client/net/TeamspeakConnection.java | 860 ++++++++++++++++++ ts3-client/desktop/pom.xml | 31 + .../ts3client/audio/desktop/AudioDevices.java | 84 ++ .../audio/desktop/JavaSoundAudioBackend.java | 32 + .../audio/desktop/JavaSoundVoiceInput.java | 371 ++++++++ .../audio/desktop/JavaSoundVoiceOutput.java | 191 ++++ .../com/ts3client/audio/desktop/Opus.java | 61 ++ .../ts3client/audio/desktop/OpusDecoder.java | 72 ++ .../ts3client/audio/desktop/OpusEncoder.java | 101 ++ ts3-client/pom.xml | 52 ++ ts3-client/swing/pom.xml | 84 ++ .../src/main/java/com/ts3client/Main.java | 33 + .../com/ts3client/ui/BookmarksDialog.java | 155 ++++ .../main/java/com/ts3client/ui/ChatPanel.java | 127 +++ .../java/com/ts3client/ui/ConnectDialog.java | 119 +++ .../ts3client/ui/ConnectionInfoDialog.java | 326 +++++++ .../src/main/java/com/ts3client/ui/Icons.java | 196 ++++ .../main/java/com/ts3client/ui/InfoPanel.java | 100 ++ .../java/com/ts3client/ui/LevelMeter.java | 69 ++ .../main/java/com/ts3client/ui/MainFrame.java | 575 ++++++++++++ .../com/ts3client/ui/ServerTreePanel.java | 247 +++++ .../java/com/ts3client/ui/SettingsDialog.java | 587 ++++++++++++ .../main/java/com/ts3client/ui/Spacers.java | 63 ++ .../src/main/java/com/ts3client/ui/Theme.java | 35 + ts3j | 1 + 48 files changed, 6347 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 ts3-client/README.md create mode 100644 ts3-client/core/pom.xml create mode 100644 ts3-client/core/src/main/java/com/ts3client/audio/AudioBackend.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/audio/AudioEnhancer.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/audio/AutomaticGainControl.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/audio/Fft.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/audio/HighPassFilter.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/audio/NoiseSuppressor.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/audio/OpusParameters.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/audio/SpeechDetector.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/audio/TypingAttenuator.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/audio/VoiceInput.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/audio/VoiceOutput.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/config/Bookmark.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/config/Bookmarks.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/config/Settings.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/net/ChannelNode.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/net/ClientEntry.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/net/ConnectionListener.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/net/ConnectionStats.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java create mode 100644 ts3-client/desktop/pom.xml create mode 100644 ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/AudioDevices.java create mode 100644 ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/JavaSoundAudioBackend.java create mode 100644 ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/JavaSoundVoiceInput.java create mode 100644 ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/JavaSoundVoiceOutput.java create mode 100644 ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/Opus.java create mode 100644 ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/OpusDecoder.java create mode 100644 ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/OpusEncoder.java create mode 100644 ts3-client/pom.xml create mode 100644 ts3-client/swing/pom.xml create mode 100644 ts3-client/swing/src/main/java/com/ts3client/Main.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/BookmarksDialog.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/ConnectDialog.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/ConnectionInfoDialog.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/LevelMeter.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/Spacers.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/Theme.java create mode 160000 ts3j diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..05b140d --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Build output +target/ +build/ +out/ +*.class + +# The bundled proprietary TeamSpeak 3 client (reference binary, not our source) +/TeamSpeak3-Client-linux_amd64/ + +# Packages +*.jar +*.war +*.zip +*.tar.gz + +# Logs / crash dumps +*.log +hs_err_pid* + +# IDE +.idea/ +*.iml +.vscode/ +.settings/ +.classpath +.project + +# Maven housekeeping +dependency-reduced-pom.xml +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +release.properties + +# OS +.DS_Store diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..6b75101 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "ts3j"] + path = ts3j + url = https://github.com/Manevolent/ts3j diff --git a/ts3-client/README.md b/ts3-client/README.md new file mode 100644 index 0000000..ac4fda6 --- /dev/null +++ b/ts3-client/README.md @@ -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. diff --git a/ts3-client/core/pom.xml b/ts3-client/core/pom.xml new file mode 100644 index 0000000..f11d981 --- /dev/null +++ b/ts3-client/core/pom.xml @@ -0,0 +1,23 @@ + + + 4.0.0 + + + com.ts3client + ts3-client-parent + 0.1.0 + + + ts3-client-core + TS3J Client Core + Frontend-agnostic library: protocol integration, model, audio abstractions + + + + com.github.manevolent + ts3j + + + diff --git a/ts3-client/core/src/main/java/com/ts3client/audio/AudioBackend.java b/ts3-client/core/src/main/java/com/ts3client/audio/AudioBackend.java new file mode 100644 index 0000000..152e55e --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/audio/AudioBackend.java @@ -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(); +} diff --git a/ts3-client/core/src/main/java/com/ts3client/audio/AudioEnhancer.java b/ts3-client/core/src/main/java/com/ts3client/audio/AudioEnhancer.java new file mode 100644 index 0000000..8f5bedb --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/audio/AudioEnhancer.java @@ -0,0 +1,238 @@ +package com.ts3client.audio; + +import java.util.Arrays; + +/** + * Microphone pre-processing chain applied before voice activation and Opus encoding, + * mirroring the capture-side denoise/typing filters of the TeamSpeak 3 client. + * + *

The stages run in the same order as the TeamSpeak client's WebRTC capture chain: + * a {@link HighPassFilter} (always-on rumble/DC removal), then a streaming short-time + * Fourier transform (square-root Hann window, 50% overlap-add) carrying a + * {@link NoiseSuppressor} ("Remove background noise") and a {@link TypingAttenuator} + * ("Typing attenuation") — sharing one FFT/IFFT per hop — and finally an + * {@link AutomaticGainControl} ("AGC"). Echo cancellation (WebRTC AEC3) is omitted as + * it requires the loudspeaker reference signal. + * + *

Input frames of any length are decoupled from the STFT hop by internal ring + * buffers; when noise/typing suppression is active the output is delayed by one hop + * (~5 ms). The high-pass filter and AGC are zero-latency. When every stage is + * disabled the chain is fully bypassed and audio passes through untouched. + * + *

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; + } + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/audio/AutomaticGainControl.java b/ts3-client/core/src/main/java/com/ts3client/audio/AutomaticGainControl.java new file mode 100644 index 0000000..4cad850 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/audio/AutomaticGainControl.java @@ -0,0 +1,66 @@ +package com.ts3client.audio; + +/** + * Automatic gain control — WebRTC APM's {@code gain_controller} (AGC2 adaptive + * digital) / Speex {@code AGC} stage. It normalises voice loudness toward a target + * level so quiet microphones are boosted and loud ones tamed, keeping perceived volume + * consistent across speakers. + * + *

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); + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/audio/Fft.java b/ts3-client/core/src/main/java/com/ts3client/audio/Fft.java new file mode 100644 index 0000000..dbc4a8e --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/audio/Fft.java @@ -0,0 +1,68 @@ +package com.ts3client.audio; + +/** + * In-place iterative radix-2 Cooley–Tukey FFT shared by the voice DSP stages. + * All arrays must have a power-of-two length. Pure math, no platform dependencies. + */ +final class Fft { + + private Fft() { + } + + /** Forward transform (unnormalised). */ + static void forward(double[] re, double[] im) { + transform(re, im, false); + } + + /** Inverse transform, normalised by {@code 1/n} so it inverts {@link #forward}. */ + static void inverse(double[] re, double[] im) { + transform(re, im, true); + int n = re.length; + double scale = 1.0 / n; + for (int i = 0; i < n; i++) { + re[i] *= scale; + im[i] *= scale; + } + } + + private static void transform(double[] re, double[] im, boolean inverse) { + int n = re.length; + for (int i = 1, j = 0; i < n; i++) { + int bit = n >> 1; + for (; (j & bit) != 0; bit >>= 1) { + j ^= bit; + } + j ^= bit; + if (i < j) { + double tr = re[i]; + re[i] = re[j]; + re[j] = tr; + double ti = im[i]; + im[i] = im[j]; + im[j] = ti; + } + } + double sign = inverse ? 2 * Math.PI : -2 * Math.PI; + for (int len = 2; len <= n; len <<= 1) { + double ang = sign / len; + double wr = Math.cos(ang); + double wi = Math.sin(ang); + for (int i = 0; i < n; i += len) { + double curR = 1, curI = 0; + for (int k = 0; k < len / 2; k++) { + int a = i + k; + int b = i + k + len / 2; + double vr = re[b] * curR - im[b] * curI; + double vi = re[b] * curI + im[b] * curR; + re[b] = re[a] - vr; + im[b] = im[a] - vi; + re[a] += vr; + im[a] += vi; + double nr = curR * wr - curI * wi; + curI = curR * wi + curI * wr; + curR = nr; + } + } + } + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/audio/HighPassFilter.java b/ts3-client/core/src/main/java/com/ts3client/audio/HighPassFilter.java new file mode 100644 index 0000000..d43f891 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/audio/HighPassFilter.java @@ -0,0 +1,43 @@ +package com.ts3client.audio; + +/** + * Second-order Butterworth high-pass filter (RBJ biquad, transposed direct form II). + * Mirrors WebRTC APM's {@code high_pass_filter} stage — an always-on part of the + * capture chain that removes DC offset, mains hum and low-frequency rumble below the + * speech band before noise suppression sees the signal. + */ +final class HighPassFilter { + + private static final double CUTOFF_HZ = 80.0; // WebRTC APM high-pass cutoff + + private final double b0, b1, b2, a1, a2; + private double z1, z2; + + HighPassFilter(int sampleRate) { + double w0 = 2 * Math.PI * CUTOFF_HZ / sampleRate; + double cos = Math.cos(w0); + double alpha = Math.sin(w0) / Math.sqrt(2.0); // Q = 1/sqrt(2) (Butterworth) + double a0 = 1 + alpha; + this.b0 = (1 + cos) / 2 / a0; + this.b1 = -(1 + cos) / a0; + this.b2 = (1 + cos) / 2 / a0; + this.a1 = -2 * cos / a0; + this.a2 = (1 - alpha) / a0; + } + + void reset() { + z1 = 0; + z2 = 0; + } + + /** Filters one mono frame in place. */ + void process(float[] buf, int len) { + for (int i = 0; i < len; i++) { + double x = buf[i]; + double y = b0 * x + z1; + z1 = b1 * x - a1 * y + z2; + z2 = b2 * x - a2 * y; + buf[i] = (float) y; + } + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/audio/NoiseSuppressor.java b/ts3-client/core/src/main/java/com/ts3client/audio/NoiseSuppressor.java new file mode 100644 index 0000000..d6986e1 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/audio/NoiseSuppressor.java @@ -0,0 +1,112 @@ +package com.ts3client.audio; + +import java.util.Arrays; + +/** + * Single-channel spectral noise suppressor — the "Remove background noise" + * (denoise) stage. The TeamSpeak 3 client filters steady background noise with + * WebRTC's {@code noise_suppression} module (and a Speex denoiser fallback); this is + * a self-contained equivalent that operates on the STFT bins produced by + * {@link AudioEnhancer}. + * + *

The noise floor is tracked per bin by continuous minimum statistics (Doblinger's + * recursive minimum tracker): the estimate follows the valleys of the smoothed power + * spectrum, so modulated speech — which dips between syllables — is + * preserved while only near-stationary background energy is learned as noise. From + * that floor a Wiener gain is formed with a decision-directed a priori SNR + * (Ephraim–Malah smoothing, which keeps musical noise low). A configurable + * aggressiveness ({@code denoiser_level}, 0–1) sets both the over-subtraction + * factor and the gain floor, i.e. how deeply steady noise is cut. + */ +final class NoiseSuppressor { + + /** Power-spectrum smoothing feeding the minimum tracker. */ + private static final double POWER_SMOOTH = 0.7; + /** Doblinger minimum-tracker constants. */ + private static final double MIN_GAMMA = 0.998; + private static final double MIN_BETA = 0.96; + /** Over-estimation applied to the tracked minimum to get the noise power. */ + private static final double NOISE_OVEREST = 1.5; + /** Decision-directed smoothing of the a priori SNR (higher = less musical noise). */ + private static final double DD_ALPHA = 0.98; + /** Floor on the a priori SNR (~ -25 dB) to bound the deepest Wiener gain. */ + private static final double XI_MIN = 0.003; + + private final int bins; + private final double[] smoothed; + private final double[] prevSmoothed; + private final double[] minTrack; + private final double[] priorClean; // previous enhanced power, for the DD estimate + + private double overSubtraction = 1.5; + private double gainFloor = dbToGain(-18); + private boolean initialised; + + NoiseSuppressor(int bins) { + this.bins = bins; + this.smoothed = new double[bins]; + this.prevSmoothed = new double[bins]; + this.minTrack = new double[bins]; + this.priorClean = new double[bins]; + } + + /** + * Sets aggressiveness in [0,1]. 0 is a light touch (~6 dB max cut), 1 is + * heavy (~30 dB) with stronger over-subtraction. + */ + void setLevel(double level) { + double l = Math.max(0, Math.min(1, level)); + this.gainFloor = dbToGain(-(6 + 24 * l)); + this.overSubtraction = 1.0 + 1.5 * l; + } + + void reset() { + initialised = false; + Arrays.fill(smoothed, 0); + Arrays.fill(prevSmoothed, 0); + Arrays.fill(minTrack, 0); + Arrays.fill(priorClean, 0); + } + + /** Multiplies the running per-bin gain by this stage's Wiener gain. */ + void apply(double[] power, double[] gain) { + if (!initialised) { + for (int k = 0; k < bins; k++) { + smoothed[k] = prevSmoothed[k] = minTrack[k] = power[k]; + priorClean[k] = power[k]; + } + initialised = true; + } + for (int k = 0; k < bins; k++) { + double p = power[k] + 1e-12; + + double s = POWER_SMOOTH * smoothed[k] + (1 - POWER_SMOOTH) * p; + // Doblinger continuous minimum tracking of the smoothed power. + double mt; + if (minTrack[k] < s) { + mt = MIN_GAMMA * minTrack[k] + + ((1 - MIN_GAMMA) / (1 - MIN_BETA)) * (s - MIN_BETA * smoothed[k]); + } else { + mt = s; + } + minTrack[k] = mt; + smoothed[k] = s; + double noiseK = NOISE_OVEREST * mt + 1e-12; + + double gamma = p / (noiseK * overSubtraction); // a posteriori SNR + double xi = DD_ALPHA * (priorClean[k] / noiseK) + + (1 - DD_ALPHA) * Math.max(gamma - 1, 0); // a priori SNR + if (xi < XI_MIN) xi = XI_MIN; + + double g = xi / (1 + xi); // Wiener gain + if (g < gainFloor) g = gainFloor; + + priorClean[k] = g * g * p; + gain[k] *= g; + } + } + + private static double dbToGain(double db) { + return Math.pow(10.0, db / 20.0); + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/audio/OpusParameters.java b/ts3-client/core/src/main/java/com/ts3client/audio/OpusParameters.java new file mode 100644 index 0000000..8edc86d --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/audio/OpusParameters.java @@ -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; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/audio/SpeechDetector.java b/ts3-client/core/src/main/java/com/ts3client/audio/SpeechDetector.java new file mode 100644 index 0000000..360df01 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/audio/SpeechDetector.java @@ -0,0 +1,112 @@ +package com.ts3client.audio; + +/** + * Lightweight speech-presence detector used by the "Automatic" and "Hybrid" voice + * activation modes (the TeamSpeak 3 client uses a WebRTC GMM detector for the same + * purpose). It classifies each 20 ms frame from three features — short-term + * energy, spectral flatness and dominant frequency — against an adaptive noise + * floor, following Moattar & Homayounpour's real-time VAD. + * + *

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; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/audio/TypingAttenuator.java b/ts3-client/core/src/main/java/com/ts3client/audio/TypingAttenuator.java new file mode 100644 index 0000000..70d7963 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/audio/TypingAttenuator.java @@ -0,0 +1,78 @@ +package com.ts3client.audio; + +/** + * Transient (keystroke) suppressor — the "Typing attenuation" stage, which per + * the TeamSpeak 3 client "tries to detect and reduce the sounds made by typing" + * (WebRTC's {@code transient_suppression} module). Key clicks are short, impulsive, + * broadband bursts with a strong high-frequency component, unlike voiced speech which + * is sustained and low-frequency dominant. + * + *

Each STFT block is scored for a keystroke signature: a sudden jump in total + * power (both against the previous block and a slow running floor) together with an + * elevated high-frequency energy ratio. Matching blocks are ducked broadband with an + * immediate attack and a short release. A hold cap ensures only genuinely brief + * events are cut — a sustained sound such as a fricative outlasts the cap and is + * released, so speech is preserved. + */ +final class TypingAttenuator { + + private static final double HF_HZ = 4000.0; // high-frequency band start + private static final double ONSET_FACTOR = 2.5; // total power vs slow floor + private static final double FLUX_FACTOR = 3.0; // total power vs previous block + private static final double HF_RATIO = 0.30; // fraction of energy above HF_HZ + private static final double SUPPRESS = 0.12; // ducking gain on a detected click (~ -18 dB) + private static final double RELEASE = 0.25; // recovery fraction per block after a click + private static final double FLOOR_SMOOTH = 0.98; // slow power-floor tracking + private static final int MAX_HOLD = 4; // max consecutive ducked blocks (~clicks only) + + private final int bins; + private final int hfBin; + + private double slowPower; + private double prevPower; + private double envGain = 1.0; + private int heldBlocks; + + TypingAttenuator(int bins, int sampleRate, int fftSize) { + this.bins = bins; + this.hfBin = (int) Math.round(HF_HZ * fftSize / sampleRate); + } + + void reset() { + slowPower = 0; + prevPower = 0; + envGain = 1.0; + heldBlocks = 0; + } + + /** Multiplies the running per-bin gain by the current broadband ducking gain. */ + void apply(double[] power, double[] gain) { + double total = 0, high = 0; + for (int k = 0; k < bins; k++) { + total += power[k]; + if (k >= hfBin) high += power[k]; + } + double hfRatio = high / (total + 1e-12); + + boolean signature = total > slowPower * ONSET_FACTOR + && total > prevPower * FLUX_FACTOR + && hfRatio > HF_RATIO; + + if (signature && heldBlocks < MAX_HOLD) { + envGain = SUPPRESS; // fast attack: duck immediately + heldBlocks++; + } else { + envGain += (1 - envGain) * RELEASE; + if (!signature) { + heldBlocks = 0; + // Only let the floor track when we're not inside a transient. + slowPower = slowPower == 0 ? total : FLOOR_SMOOTH * slowPower + (1 - FLOOR_SMOOTH) * total; + } + } + prevPower = total; + + for (int k = 0; k < bins; k++) { + gain[k] *= envGain; + } + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/audio/VoiceInput.java b/ts3-client/core/src/main/java/com/ts3client/audio/VoiceInput.java new file mode 100644 index 0000000..06bf381 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/audio/VoiceInput.java @@ -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 listener); + + /** Receives local transmit-state transitions (talking / silent). */ + void setTalkListener(Consumer listener); +} diff --git a/ts3-client/core/src/main/java/com/ts3client/audio/VoiceOutput.java b/ts3-client/core/src/main/java/com/ts3client/audio/VoiceOutput.java new file mode 100644 index 0000000..c5ebe1d --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/audio/VoiceOutput.java @@ -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 listener); +} diff --git a/ts3-client/core/src/main/java/com/ts3client/config/Bookmark.java b/ts3-client/core/src/main/java/com/ts3client/config/Bookmark.java new file mode 100644 index 0000000..6d757bf --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/config/Bookmark.java @@ -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(); + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/config/Bookmarks.java b/ts3-client/core/src/main/java/com/ts3client/config/Bookmarks.java new file mode 100644 index 0000000..07ea106 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/config/Bookmarks.java @@ -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 entries = new ArrayList<>(); + + public List 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; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/config/Settings.java b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java new file mode 100644 index 0000000..67e8134 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java @@ -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). + * + *

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); + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ChannelNode.java b/ts3-client/core/src/main/java/com/ts3client/net/ChannelNode.java new file mode 100644 index 0000000..b8e1ff2 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/ChannelNode.java @@ -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 children = new ArrayList<>(); + public final List clients = new ArrayList<>(); + + public ChannelNode(int id, String name) { + this.id = id; + this.name = name; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ClientEntry.java b/ts3-client/core/src/main/java/com/ts3client/net/ClientEntry.java new file mode 100644 index 0000000..896fd23 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/ClientEntry.java @@ -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; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionListener.java b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionListener.java new file mode 100644 index 0000000..1b5d615 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionListener.java @@ -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); +} diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionStats.java b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionStats.java new file mode 100644 index 0000000..0ac767f --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionStats.java @@ -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. + * + *

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 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; + } + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java new file mode 100644 index 0000000..45ea941 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java @@ -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). + * + *

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 channels = new LinkedHashMap<>(); + private final Map clients = new LinkedHashMap<>(); + private final Map serverGroups = new LinkedHashMap<>(); + private final Map 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 serverGroupNames(int[] ids) { + java.util.List 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 buildTree() { + // Reset transient child/client lists. + for (ChannelNode c : channels.values()) { + c.children.clear(); + c.clients.clear(); + } + List 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 byOrder = Comparator.comparingInt((ChannelNode c) -> c.order) + .thenComparing(c -> c.name == null ? "" : c.name.toLowerCase()); + Comparator 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; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java new file mode 100644 index 0000000..a39c905 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java @@ -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 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 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 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 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 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 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 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_*_} 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 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 callback; + final ConnectionStats stats; + + PendingConnInfo(Consumer 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(); + } +} diff --git a/ts3-client/desktop/pom.xml b/ts3-client/desktop/pom.xml new file mode 100644 index 0000000..a3e0ee3 --- /dev/null +++ b/ts3-client/desktop/pom.xml @@ -0,0 +1,31 @@ + + + 4.0.0 + + + com.ts3client + ts3-client-parent + 0.1.0 + + + ts3-client-desktop + TS3J Client Desktop Audio + Desktop audio backend: Java Sound capture/playback and native Opus via JNA + + + + com.ts3client + ts3-client-core + + + com.github.manevolent + ts3j + + + net.java.dev.jna + jna + + + diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/AudioDevices.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/AudioDevices.java new file mode 100644 index 0000000..f34674e --- /dev/null +++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/AudioDevices.java @@ -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. + * + *

The TS3 protocol uses 48 kHz, 16-bit, mono/stereo signed little-endian + * PCM for Opus. We standardise on that format everywhere. + */ +public final class AudioDevices { + + public static final int SAMPLE_RATE = 48_000; + public static final int FRAME_SIZE = 960; // 20 ms @ 48 kHz + public static final AudioFormat CAPTURE_FORMAT = + new AudioFormat(SAMPLE_RATE, 16, 1, true, false); // mono capture + public static final AudioFormat PLAYBACK_FORMAT = + new AudioFormat(SAMPLE_RATE, 16, 1, true, false); // mono playback + + private AudioDevices() { + } + + /** Names of mixers that can provide microphone (capture) lines. */ + public static List inputDeviceNames() { + return deviceNames(new DataLine.Info(TargetDataLine.class, CAPTURE_FORMAT)); + } + + /** Names of mixers that can provide speaker (playback) lines. */ + public static List outputDeviceNames() { + return deviceNames(new DataLine.Info(SourceDataLine.class, PLAYBACK_FORMAT)); + } + + private static List deviceNames(Line.Info lineInfo) { + List 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 + } +} diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/JavaSoundAudioBackend.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/JavaSoundAudioBackend.java new file mode 100644 index 0000000..1bd2b7b --- /dev/null +++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/JavaSoundAudioBackend.java @@ -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)"; + } + } +} diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/JavaSoundVoiceInput.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/JavaSoundVoiceInput.java new file mode 100644 index 0000000..efca4c0 --- /dev/null +++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/JavaSoundVoiceInput.java @@ -0,0 +1,371 @@ +package com.ts3client.audio.desktop; + +import com.github.manevolent.ts3j.enums.CodecType; +import com.ts3client.audio.AudioEnhancer; +import com.ts3client.audio.OpusParameters; +import com.ts3client.audio.SpeechDetector; +import com.ts3client.audio.VoiceInput; +import com.ts3client.config.Settings; + +import javax.sound.sampled.TargetDataLine; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +/** + * Java Sound {@link VoiceInput}: captures the microphone, applies voice-activation + * or push-to-talk gating, and Opus-encodes 20 ms frames. + * + *

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 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 levelListener; // input level in dBFS + private volatile Consumer 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 l) { + this.levelListener = l; + } + + public void setTalkListener(Consumer 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 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 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]; + } +} diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/JavaSoundVoiceOutput.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/JavaSoundVoiceOutput.java new file mode 100644 index 0000000..7c2b515 --- /dev/null +++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/JavaSoundVoiceOutput.java @@ -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 streams = new ConcurrentHashMap<>(); + private final Set 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 talkListener; + + public JavaSoundVoiceOutput(String outputDevice) { + this.outputDevice = outputDevice; + } + + public void setTalkListener(BiConsumer 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 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(); + } +} diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/Opus.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/Opus.java new file mode 100644 index 0000000..7c80728 --- /dev/null +++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/Opus.java @@ -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). + * + *

We bind directly to the system-installed {@code libopus.so}/{@code opus.dll} + * rather than relying on a bundled wrapper. Opus always operates internally at + * 48 kHz which matches what the TeamSpeak 3 protocol uses on the wire. + */ +public interface Opus extends Library { + + Opus INSTANCE = Native.load("opus", Opus.class); + + // ---- application types (opus_defines.h) ---- + int OPUS_APPLICATION_VOIP = 2048; + int OPUS_APPLICATION_AUDIO = 2049; + int OPUS_APPLICATION_RESTRICTED_LOWDELAY = 2051; + + // ---- CTL request codes ---- + int OPUS_SET_BITRATE_REQUEST = 4002; + int OPUS_SET_VBR_REQUEST = 4006; + int OPUS_SET_COMPLEXITY_REQUEST = 4010; + int OPUS_SET_INBAND_FEC_REQUEST = 4012; + int OPUS_SET_PACKET_LOSS_PERC_REQUEST = 4014; + int OPUS_SET_SIGNAL_REQUEST = 4024; + int OPUS_RESET_STATE = 4028; + + // ---- signal hints ---- + int OPUS_AUTO = -1000; + int OPUS_SIGNAL_VOICE = 3001; + int OPUS_SIGNAL_MUSIC = 3002; + + // ---- encoder ---- + PointerByReference opus_encoder_create(int fs, int channels, int application, IntBuffer error); + + int opus_encode_float(PointerByReference st, float[] pcm, int frameSize, byte[] data, int maxDataBytes); + + int opus_encoder_ctl(PointerByReference st, int request, Object... args); + + void opus_encoder_destroy(PointerByReference st); + + // ---- decoder ---- + PointerByReference opus_decoder_create(int fs, int channels, IntBuffer error); + + int opus_decode_float(PointerByReference st, byte[] data, int len, float[] pcm, int frameSize, int decodeFec); + + int opus_decoder_ctl(PointerByReference st, int request, Object... args); + + void opus_decoder_destroy(PointerByReference st); + + // ---- misc ---- + String opus_get_version_string(); + + String opus_strerror(int error); +} diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/OpusDecoder.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/OpusDecoder.java new file mode 100644 index 0000000..c3c9bd1 --- /dev/null +++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/OpusDecoder.java @@ -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. + * + *

Created as mono at 48 kHz; Opus transparently down-mixes stereo streams + * (e.g. music-bot audio) to the requested channel count, so a single mono decoder + * copes with both {@code OPUS_VOICE} and {@code OPUS_MUSIC} payloads. + */ +public final class OpusDecoder implements AutoCloseable { + + private final PointerByReference handle; + private final int frameSize; + private final int channels; + private boolean closed; + + public OpusDecoder(int sampleRate, int frameSize, int channels) { + this.frameSize = frameSize; + this.channels = channels; + + IntBuffer error = IntBuffer.allocate(1); + handle = Opus.INSTANCE.opus_decoder_create(sampleRate, channels, error); + if (handle == null || error.get(0) != 0) { + throw new IllegalStateException("opus_decoder_create failed: " + + Opus.INSTANCE.opus_strerror(error.get(0))); + } + } + + /** + * Decodes an Opus packet to interleaved float PCM. + * + * @param packet the encoded packet, or {@code null} to request packet-loss + * concealment (PLC) for a missing frame + * @param out output buffer, at least {@code frameSize * channels} long + * @return number of samples decoded per channel + */ + public int decode(byte[] packet, float[] out) { + if (closed) throw new IllegalStateException("decoder closed"); + int samples = Opus.INSTANCE.opus_decode_float( + handle, + packet, + packet == null ? 0 : packet.length, + out, + frameSize, + 0); + if (samples < 0) { + throw new IllegalStateException("opus_decode_float failed: " + + Opus.INSTANCE.opus_strerror(samples)); + } + return samples; + } + + public void reset() { + if (closed) return; + Opus.INSTANCE.opus_decoder_ctl(handle, Opus.OPUS_RESET_STATE); + } + + public int getChannels() { + return channels; + } + + @Override + public void close() { + if (closed) return; + closed = true; + Opus.INSTANCE.opus_decoder_destroy(handle); + } +} diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/OpusEncoder.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/OpusEncoder.java new file mode 100644 index 0000000..658af4c --- /dev/null +++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/OpusEncoder.java @@ -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. + * + *

Fixed at 48 kHz. Frames are 20 ms (960 samples per channel), which + * is the frame size the TS3 client uses. + */ +public final class OpusEncoder implements AutoCloseable { + + private final PointerByReference handle; + private final int frameSize; + private final int channels; + private final byte[] out = new byte[4096]; + private final Object lock = new Object(); + private boolean closed; + + public OpusEncoder(int sampleRate, int frameSize, int channels, int application) { + this.frameSize = frameSize; + this.channels = channels; + + IntBuffer error = IntBuffer.allocate(1); + handle = Opus.INSTANCE.opus_encoder_create(sampleRate, channels, application, error); + if (handle == null || error.get(0) != 0) { + throw new IllegalStateException("opus_encoder_create failed: " + + Opus.INSTANCE.opus_strerror(error.get(0))); + } + } + + public void setBitrate(int bitsPerSecond) { + ctl(Opus.OPUS_SET_BITRATE_REQUEST, bitsPerSecond); + } + + public void setComplexity(int complexity) { + ctl(Opus.OPUS_SET_COMPLEXITY_REQUEST, Math.max(0, Math.min(10, complexity))); + } + + public void setVbr(boolean vbr) { + ctl(Opus.OPUS_SET_VBR_REQUEST, vbr ? 1 : 0); + } + + public void setInbandFec(boolean fec) { + ctl(Opus.OPUS_SET_INBAND_FEC_REQUEST, fec ? 1 : 0); + } + + public void setExpectedPacketLoss(int percent) { + ctl(Opus.OPUS_SET_PACKET_LOSS_PERC_REQUEST, Math.max(0, Math.min(100, percent))); + } + + public void setSignal(int signal) { + ctl(Opus.OPUS_SET_SIGNAL_REQUEST, signal); + } + + private void ctl(int request, int value) { + synchronized (lock) { + if (closed) return; + int r = Opus.INSTANCE.opus_encoder_ctl(handle, request, value); + if (r < 0) { + throw new IllegalStateException("opus_encoder_ctl(" + request + ") failed: " + + Opus.INSTANCE.opus_strerror(r)); + } + } + } + + /** + * Encodes one frame of interleaved float PCM ({@code frameSize * channels} samples) + * into an Opus packet. + * + * @return a newly allocated byte array holding the encoded packet + */ + public byte[] encode(float[] pcm) { + if (pcm.length != frameSize * channels) { + throw new IllegalArgumentException("expected " + (frameSize * channels) + + " samples, got " + pcm.length); + } + synchronized (lock) { + if (closed) throw new IllegalStateException("encoder closed"); + int len = Opus.INSTANCE.opus_encode_float(handle, pcm, frameSize, out, out.length); + if (len < 0) { + throw new IllegalStateException("opus_encode_float failed: " + + Opus.INSTANCE.opus_strerror(len)); + } + byte[] packet = new byte[len]; + System.arraycopy(out, 0, packet, 0, len); + return packet; + } + } + + @Override + public void close() { + synchronized (lock) { + if (closed) return; + closed = true; + Opus.INSTANCE.opus_encoder_destroy(handle); + } + } +} diff --git a/ts3-client/pom.xml b/ts3-client/pom.xml new file mode 100644 index 0000000..1b7384a --- /dev/null +++ b/ts3-client/pom.xml @@ -0,0 +1,52 @@ + + + 4.0.0 + + com.ts3client + ts3-client-parent + 0.1.0 + pom + + TS3J Client (parent) + Desktop TeamSpeak 3 client built on the ts3j protocol library + + + core + desktop + swing + + + + 17 + UTF-8 + 1.0.3 + 5.14.0 + + + + + + com.github.manevolent + ts3j + ${ts3j.version} + + + net.java.dev.jna + jna + ${jna.version} + + + com.ts3client + ts3-client-core + ${project.version} + + + com.ts3client + ts3-client-desktop + ${project.version} + + + + diff --git a/ts3-client/swing/pom.xml b/ts3-client/swing/pom.xml new file mode 100644 index 0000000..807e6ec --- /dev/null +++ b/ts3-client/swing/pom.xml @@ -0,0 +1,84 @@ + + + 4.0.0 + + + com.ts3client + ts3-client-parent + 0.1.0 + + + ts3-client-swing + TS3J Client Swing Frontend + Swing desktop UI + + + com.ts3client.Main + + + + + com.ts3client + ts3-client-core + + + com.ts3client + ts3-client-desktop + + + com.github.manevolent + ts3j + + + + + ts3-client + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.1 + + + package + + shade + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + module-info.class + + + + + + com.ts3client.Main + + ALL-UNNAMED + + + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + com.ts3client.Main + + + + + diff --git a/ts3-client/swing/src/main/java/com/ts3client/Main.java b/ts3-client/swing/src/main/java/com/ts3client/Main.java new file mode 100644 index 0000000..45a1af0 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/Main.java @@ -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); + }); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/BookmarksDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/BookmarksDialog.java new file mode 100644 index 0000000..cf4315a --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/BookmarksDialog.java @@ -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 onConnect; + private final Runnable onChanged; + private final DefaultListModel listModel = new DefaultListModel<>(); + private final JList list = new JList<>(listModel); + + public BookmarksDialog(Frame owner, Bookmarks bookmarks, Consumer 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; + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java new file mode 100644 index 0000000..dc6596b --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java @@ -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 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; + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ConnectDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ConnectDialog.java new file mode 100644 index 0000000..2b268f3 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ConnectDialog.java @@ -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()); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ConnectionInfoDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ConnectionInfoDialog.java new file mode 100644 index 0000000..5793a2f --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ConnectionInfoDialog.java @@ -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 + * Total / Speech / Keep Alive / Control tab strip, each tab showing + * that category's packet loss, packet and byte totals and live bandwidth. + * + *

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(); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java b/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java new file mode 100644 index 0000000..d29758c --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java @@ -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×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); + }); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java new file mode 100644 index 0000000..d34e965 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java @@ -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("Select a channel or client to see details."); + } + + 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("


"); + if (ch.description != null && !ch.description.isEmpty()) { + sb.append("
").append(multiline(ch.description)).append("
"); + } else if (ch.descriptionLoaded) { + sb.append("No description."); + } else { + sb.append("Loading description…"); + } + setHtml(sb.toString()); + } + + public void showClient(ClientEntry cl, ServerModel model) { + StringBuilder sb = new StringBuilder(); + sb.append(heading(esc(cl.nickname) + (cl.self ? " (you)" : ""))); + + List 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("
").append(multiline(cl.description)).append("
"); + } + setHtml(sb.toString()); + } + + private void setHtml(String body) { + pane.setText("" + + body + ""); + pane.setCaretPosition(0); + } + + private static String heading(String text) { + return "
" + text + "
"; + } + + private static void row(StringBuilder sb, String label, String value) { + sb.append("
") + .append(label).append(": ").append(value).append("
"); + } + + private static String multiline(String text) { + return esc(text).replace("\n", "
"); + } + + private static String esc(String s) { + if (s == null) return ""; + return s.replace("&", "&").replace("<", "<").replace(">", ">"); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/LevelMeter.java b/ts3-client/swing/src/main/java/com/ts3client/ui/LevelMeter.java new file mode 100644 index 0000000..831f58a --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/LevelMeter.java @@ -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); + } + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java new file mode 100644 index 0000000..4490cfa --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java @@ -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); + }); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java new file mode 100644 index 0000000..f60d265 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java @@ -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 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(); + } + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java new file mode 100644 index 0000000..ab6b02f --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java @@ -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 inputCombo; + private JComboBox 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 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 ins = AudioDevices.inputDeviceNames(); + List 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("Typing attenuation tries to detect and " + + "reduce the sounds made by typing."); + agcCheck = new JCheckBox("Automatic gain control (AGC)", settings.agc); + agcCheck.setToolTipText("Automatic gain control normalises your " + + "microphone loudness to a target level, boosting quiet mics and taming " + + "loud ones."); + + 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("Automatic: intelligent speech detection.
" + + "Volume Gate: transmit when loud enough.
" + + "Hybrid: loud enough and detected as speech."); + 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 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 combo) { + int idx = combo.getSelectedIndex(); + if (idx <= 0) return ""; + Object v = combo.getSelectedItem(); + return v == null ? "" : v.toString(); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/Spacers.java b/ts3-client/swing/src/main/java/com/ts3client/ui/Spacers.java new file mode 100644 index 0000000..593c46f --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/Spacers.java @@ -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; + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/Theme.java b/ts3-client/swing/src/main/java/com/ts3client/ui/Theme.java new file mode 100644 index 0000000..e31cbed --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/Theme.java @@ -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() { + } +} diff --git a/ts3j b/ts3j new file mode 160000 index 0000000..db57d60 --- /dev/null +++ b/ts3j @@ -0,0 +1 @@ +Subproject commit db57d60c989e399626aa16d921390f5033e6cdeb