aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPinapelz <yukais@pinapelz.com>2026-10-29 19:58:05 -0700
committerPinapelz <yukais@pinapelz.com>2026-10-29 19:58:05 -0700
commit96bfe2e1505b4ae250f2d5201f9541ceb01cec84 (patch)
treeb2164a76dc3aa5b69e160d1135d3385bcbe6108d
parent150858067059ba615c548f826bb343589993b4bb (diff)
ts web slopify code
-rw-r--r--.dockerignore9
-rw-r--r--.env.template11
-rw-r--r--.gitignore3
-rw-r--r--Dockerfile44
-rw-r--r--README.md55
-rw-r--r--__pycache__/app.cpython-313.pycbin15762 -> 25145 bytes
-rw-r--r--__pycache__/config.cpython-313.pycbin1494 -> 6022 bytes
-rw-r--r--app.py283
-rw-r--r--cache.json2
-rw-r--r--config.py170
-rw-r--r--config.toml93
-rw-r--r--docker-compose.yml25
-rw-r--r--gateway/package-lock.json107
-rw-r--r--gateway/package.json21
-rw-r--r--gateway/src/auth.js83
-rw-r--r--gateway/src/config.js97
-rw-r--r--gateway/src/index.js39
-rw-r--r--gateway/src/opus.js59
-rw-r--r--gateway/src/server.js128
-rw-r--r--gateway/src/voice-session.js500
-rw-r--r--index.css777
-rw-r--r--pyproject.toml1
-rw-r--r--requirements.txt3
-rw-r--r--run.py121
-rw-r--r--static/mic-worklet.js60
-rw-r--r--static/voice.js1145
-rw-r--r--uv.lock13
27 files changed, 3702 insertions, 147 deletions
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..8c7f8db
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,9 @@
+.git
+.gitignore
+.venv
+__pycache__
+*.pyc
+.env
+cache.json
+reference
+gateway/node_modules
diff --git a/.env.template b/.env.template
index 648a522..80f0bab 100644
--- a/.env.template
+++ b/.env.template
@@ -1,4 +1,13 @@
+# Secrets and per-deployment overrides. Read by BOTH the dashboard and the
+# voice gateway. Everything else lives in config.toml.
TS_HOST=
-TS_PORT=
+TS_API_KEY=
+# Must match the TeamSpeak server password. Required for web voice: visitors
+# have to type it to join, and voice stays disabled while it is empty.
+TS_SERVER_PASSWORD=
+
+# Optional overrides of config.toml values
TS_WEBQUERY_PORT=
TS_SERVER_ID=
+TS_VOICE_PORT=
+WEB_PORT=
diff --git a/.gitignore b/.gitignore
index 30d44f6..b39b5d0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -222,3 +222,6 @@ __marimo__/
.jpg
.png
cache.json
+
+# Voice gateway (Node)
+gateway/node_modules/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..f82982c
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,44 @@
+# Multi-arch (linux/amd64, linux/arm64) image for the TeamSpeak 6 dashboard + browser voice gateway.
+FROM node:22-bookworm-slim AS node-runtime
+
+FROM python:3.13-slim-bookworm
+
+# Copy Node.js 22 runtime (required by @honeybbq/teamspeak and process.loadEnvFile)
+COPY --from=node-runtime /usr/local/bin/node /usr/local/bin/node
+COPY --from=node-runtime /usr/local/lib/node_modules /usr/local/lib/node_modules
+RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
+ && useradd --create-home --shell /bin/bash appuser
+
+WORKDIR /app
+
+# Install Python dependencies first (cached layer)
+COPY pyproject.toml ./
+RUN pip install --no-cache-dir \
+ "flask>=3.1.3" \
+ "flask-cors>=6.0.5" \
+ "python-dotenv>=1.0.1" \
+ "requests>=2.34.2"
+
+# Install Node.js voice gateway dependencies (pure JS + WASM, no native compilation needed on arm64)
+COPY gateway/package.json gateway/package-lock.json* ./gateway/
+RUN npm --prefix ./gateway ci --omit=dev --no-audit --no-fund \
+ || npm --prefix ./gateway install --omit=dev --no-audit --no-fund
+
+# Copy application source
+COPY app.py config.py run.py config.toml index.css ./
+COPY static ./static
+COPY gateway/src ./gateway/src
+
+RUN chown -R appuser:appuser /app
+USER appuser
+
+ENV PYTHONUNBUFFERED=1 \
+ WEB_HOST=0.0.0.0 \
+ WEB_PORT=5000
+
+EXPOSE 5000
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
+ CMD node -e "fetch('http://127.0.0.1:' + (process.env.WEB_PORT || 5000) + '/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
+
+CMD ["python", "run.py"]
diff --git a/README.md b/README.md
index 09c5d66..125af65 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,40 @@
-# Teamspeak 6 Web Server Status
-A simple webview of your Teamspeak 6 Server. This is a Python re-write of [ts6-web-explorer](https://github.com/Guraz/ts6-web-explorer) along with a visual overhaul
-
-<img width="852" height="906" alt="image" src="https://github.com/user-attachments/assets/00cf9ea7-cfad-4633-8d3b-7a66039fb87b" />
-
-# Notes
-```
-uv sync
-uv run app.py
-```
-- Rename `.env.template` to `.env` and fill in the parameters as required
-- Spacer channels are hidden
-- Channels with the text `AFK` are shown, the number of users in this channel is used as the AFK users indicator
-- Edit and configure `config.py` for all other non-essential settings
-- Images in `config.py` are referenced based on the root of this repo
+# Teamspeak 6 Web Server Status
+A simple webview of your Teamspeak 6 Server. This is a Python re-write of [ts6-web-explorer](https://github.com/Guraz/ts6-web-explorer) along with a visual overhaul
+
+<img width="852" height="906" alt="image" src="https://github.com/user-attachments/assets/00cf9ea7-cfad-4633-8d3b-7a66039fb87b" />
+
+Visitors who know the server password can also **join voice straight from the browser**, no TeamSpeak client needed.
+
+# Running
+Requires Python 3.13+ (uv) and, for browser voice, Node.js 22.12+.
+```
+uv sync
+uv run run.py
+```
+That one command starts the dashboard **and** the voice gateway (gateway npm dependencies are installed automatically on first run). Open `http://<host>:5000`.
+
+- Copy `.env.template` to `.env` and fill in the secrets (`TS_HOST`, `TS_API_KEY`, `TS_SERVER_PASSWORD`)
+- Everything else is configured in `config.toml`, which is shared by the dashboard and the voice gateway
+- Images in `config.toml` are referenced based on the root of this repo
+- Spacer channels are hidden
+- Channels with the text `AFK` are shown, the number of users in this channel is used as the AFK users indicator
+- `uv run app.py` still runs the dashboard alone (no voice)
+
+# Browser voice
+```
+browser ──HTTP/WS :5000──▶ gateway (Node) ──proxy──▶ dashboard (Flask, 127.0.0.1:5001)
+ │
+ └──UDP voice──▶ TeamSpeak server :9987
+```
+- The gateway owns the public port: it serves `/ws/voice` and forwards everything else to Flask, so there is only one port to expose / reverse-proxy.
+- Each browser gets its own TeamSpeak client inside the gateway (via [`@honeybbq/teamspeak`](https://github.com/HoneyBBQ/teamspeak-js), pure JS). Mic audio is sent as 20 ms PCM frames over the WebSocket and encoded to Opus on the gateway; incoming Opus is decoded in the browser with WebCodecs (or on the gateway for browsers without it). No WebRTC.
+- **Access control:** voice is only offered when `TS_SERVER_PASSWORD` is set. Visitors must type the server password; the gateway checks it (constant time) *before* opening any TeamSpeak connection, and the TeamSpeak server checks it again. Repeated wrong passwords lock the IP out (`[voice] max_failed_logins`, `lockout_minutes`). Cross-origin WebSocket connections are rejected.
+- Limits for small machines: `[voice] max_sessions` and `max_sessions_per_ip`.
+- While connected, the channel list is rendered live from the voice session (talking indicators, join buttons per channel, password-protected channels prompt for their password).
+- Controls: mute, deafen, voice activity (with sensitivity) or push-to-talk (hold the button or `V`), output volume.
+
+## Deployment notes
+- **Browsers only allow microphone access on HTTPS or `localhost`.** Put the app behind a TLS reverse proxy (Caddy, nginx, ...) and forward WebSocket upgrades for `/ws/voice`. If your proxy rewrites the `Host` header, add your public origin to `[voice] allowed_origins`; set `trust_proxy = true` so per-IP limits use `X-Forwarded-For`.
+- **arm64 / SBCs:** everything is pure JS/WASM (Opus via `opusscript`), so `npm` needs no compiler. If you want lower CPU use, `cd gateway && npm install @discordjs/opus` (native addon) and the gateway will pick it up automatically.
+- The gateway needs outbound UDP to the TeamSpeak voice port (`TS_VOICE_PORT`, default 9987).
+- Gateway tests: `cd gateway && npm test`.
diff --git a/__pycache__/app.cpython-313.pyc b/__pycache__/app.cpython-313.pyc
index c01cd98..be184c3 100644
--- a/__pycache__/app.cpython-313.pyc
+++ b/__pycache__/app.cpython-313.pyc
Binary files differ
diff --git a/__pycache__/config.cpython-313.pyc b/__pycache__/config.cpython-313.pyc
index eda5147..afde1ca 100644
--- a/__pycache__/config.cpython-313.pyc
+++ b/__pycache__/config.cpython-313.pyc
Binary files differ
diff --git a/app.py b/app.py
index 19c8ccf..2b10dcd 100644
--- a/app.py
+++ b/app.py
@@ -1,4 +1,5 @@
import json
+import os
import time
from pathlib import Path
from urllib.parse import quote
@@ -7,11 +8,24 @@ import requests
from flask import Flask, Response, render_template_string, request, send_from_directory
from flask_cors import CORS
-from config import BASE_DIR, CONFIG
+from config import BASE_DIR, CONFIG, VOICE_AVAILABLE
-app = Flask(__name__)
+app = Flask(__name__, static_folder=str(BASE_DIR / "static"), static_url_path="/static")
CORS(app)
+# Directories that must never be served by the catch-all asset route.
+PRIVATE_DIRS = {"gateway", "reference", "static", ".git", ".venv", "__pycache__"}
+
+# Everything the browser voice client needs; never include secrets here.
+VOICE_PUBLIC_CONFIG = {
+ "enabled": VOICE_AVAILABLE,
+ "wsPath": "/ws/voice",
+ "channelBanners": CONFIG["channel_banners"],
+ "channelIcons": CONFIG["channel_icons"],
+ "genericImages": CONFIG["generic_images"],
+ "roleIcons": CONFIG["role_icons"],
+}
+
ALLOWED_STATIC_EXTENSIONS = {
".css",
".png",
@@ -30,6 +44,72 @@ def safe_url(filename):
+CLIENT_DETAIL_FIELDS = ("afk_text", "is_afk", "is_streaming", "stream_text")
+# After a failed per-user clientinfo call, skip per-user calls for this long so a
+# misbehaving server is not hammered on every refresh.
+CLIENT_DETAILS_RETRY_SECONDS = 300
+_client_details_paused_until = 0.0
+
+
+class WebQueryError(Exception):
+ """The WebQuery answered, but with an error (HTTP or TeamSpeak status)."""
+
+ def __init__(self, message, code=None):
+ super().__init__(message)
+ self.code = code
+
+
+# TeamSpeak "invalid clientID": the user left between clientlist and clientinfo.
+TS_ERROR_INVALID_CLIENT_ID = "512"
+
+
+def webquery_get(url, headers):
+ res = requests.get(url, headers=headers, timeout=2)
+ res.raise_for_status()
+ payload = res.json()
+ status = payload.get("status") if isinstance(payload, dict) else None
+ if isinstance(status, dict) and str(status.get("code", 0)) != "0":
+ code = str(status.get("code"))
+ raise WebQueryError(f"{code}: {status.get('message', '')}", code=code)
+ return payload
+
+
+def fetch_client_details(base_url, headers, client_list):
+ """Add AFK/streaming state per user via clientinfo.
+
+ All or nothing: if any call fails, every user's details are dropped and the
+ plain channel/user list is used instead. Returns True on success.
+ """
+ global _client_details_paused_until
+ try:
+ for client in client_list:
+ clid = client.get("clid")
+ if not clid or client.get("client_nickname") == "serveradmin":
+ continue
+ body = None
+ try:
+ body = webquery_get(f"{base_url}/clientinfo?clid={clid}", headers).get("body")
+ except WebQueryError as e:
+ if e.code != TS_ERROR_INVALID_CLIENT_ID:
+ raise
+ client_info = body[0] if isinstance(body, list) and body else {}
+ client["afk_text"] = client_info.get("client_away_message", "")
+ client["is_afk"] = client_info.get("client_away") == "1"
+ client["is_streaming"] = client_info.get("client_is_streaming") == "1"
+ client["stream_text"] = client_info.get("client_stream_text", "")
+ return True
+ except (requests.RequestException, ValueError, WebQueryError) as e:
+ print(
+ f"clientinfo failed ({e}); showing users without AFK/streaming details "
+ f"for the next {CLIENT_DETAILS_RETRY_SECONDS}s"
+ )
+ _client_details_paused_until = time.time() + CLIENT_DETAILS_RETRY_SECONDS
+ for client in client_list:
+ for field in CLIENT_DETAIL_FIELDS:
+ client.pop(field, None)
+ return False
+
+
def fetch_teamspeak_data(cfg):
cache_file = Path(cfg["cache_file"])
if cache_file.exists() and (time.time() - cache_file.stat().st_mtime) < cfg["cache_time"]:
@@ -42,43 +122,21 @@ def fetch_teamspeak_data(cfg):
headers = {"x-api-key": cfg["api_key"]}
try:
- channels_res = requests.get(f"{base_url}/channellist", headers=headers, timeout=2)
- clients_res = requests.get(f"{base_url}/clientlist?-groups", headers=headers, timeout=2)
- channels_json = channels_res.json()
- clients_json = clients_res.json()
-
- client_list = clients_json.get("body") or []
+ channels_json = webquery_get(f"{base_url}/channellist", headers)
+ clients_json = webquery_get(f"{base_url}/clientlist?-groups", headers)
+ except (requests.RequestException, ValueError, WebQueryError) as e:
+ print(f"Failed to fetch channel/client list: {e}")
+ return {"channels": [], "clients": []}
- afk_threshold = cfg.get("afk_status_max_clients")
- fetch_afk_status = afk_threshold is None or len(client_list) <= afk_threshold
+ client_list = clients_json.get("body") or []
- for client in client_list:
- clid = client.get("clid")
- nickname = client.get("client_nickname")
- if not fetch_afk_status:
- continue
- if clid and nickname != "serveradmin":
- client_info_res = requests.get(
- f"{base_url}/clientinfo?clid={clid}",
- headers=headers,
- timeout=2,
- )
- body = client_info_res.json().get("body")
- if isinstance(body, list):
- client_info = body[0] if body else {}
- else:
- client_info = {}
- client["afk_text"] = client_info.get("client_away_message", "")
- client["is_afk"] = client_info.get("client_away") == "1"
- client["is_streaming"] = client_info.get("client_is_streaming") == "1"
- client["stream_text"] = client_info.get("client_stream_text", "")
-
- except requests.RequestException as e:
- print(f"RequestException: failed to fetch client info: {e}")
- return {"channels": [], "clients": []}
- except ValueError as e:
- print(f"ValueError: failed to parse client info: {e}")
- return {"channels": [], "clients": []}
+ afk_threshold = cfg.get("afk_status_max_clients")
+ fetch_afk_status = (
+ (afk_threshold is None or len(client_list) <= afk_threshold)
+ and time.time() >= _client_details_paused_until
+ )
+ if fetch_afk_status:
+ fetch_client_details(base_url, headers, client_list)
data = {
"channels": channels_json.get("body") or [],
@@ -163,6 +221,8 @@ def build_context():
"safe_url": safe_url,
"total_real_clients": total_real_clients,
"afk_count": afk_count,
+ "voice_available": VOICE_AVAILABLE,
+ "voice_public": VOICE_PUBLIC_CONFIG,
}
@@ -195,7 +255,7 @@ FRAGMENT_TEMPLATE = """
{% endif %}
{% if not channel.is_spacer %}
- <article class="channel">
+ <article class="channel" data-cid="{{ cid }}">
<header class="channel-header" style="{{ bg_style }}">
<div class="channel-title-group">
{% if icon_img %}
@@ -204,15 +264,20 @@ FRAGMENT_TEMPLATE = """
<h2>{{ c_name }}</h2>
</div>
{# Only show client count for non-spacer channels #}
- <span class="channel-meta">
- {{ channel_clients|length }}
- </span>
+ <div class="channel-actions">
+ <span class="channel-meta">
+ {{ channel_clients|length }}
+ </span>
+ {% if voice_available %}
+ <button type="button" class="channel-join" data-cid="{{ cid }}" data-name="{{ c_name }}" aria-label="Join {{ c_name }} from the browser">join</button>
+ {% endif %}
+ </div>
</header>
{# Only show client list for non-spacer channels #}
<ul class="client-list">
{% for client in channel_clients %}
- <li class="client">
+ <li class="client" data-clid="{{ client.get('clid', '') }}">
{% set role_icon = namespace(path='') %}
{% for group_id in (client.get('client_servergroups', '')|string).split(',') %}
{% if not role_icon.path and group_id in config.role_icons %}
@@ -270,11 +335,23 @@ PAGE_TEMPLATE = """
<main class="app-container">
{% if is_online %}<div class="progress-bar" id="refresh-bar"></div>{% endif %}
+ {% if voice_available %}
+ <section class="voice-topbar" id="voice-topbar" aria-label="Browser voice setup">
+ <div class="voice-topbar-bar">
+ <span class="voice-topbar-identity" id="voice-topbar-identity">[ not logged in ]</span>
+ <button type="button" class="voice-login-btn" id="voice-login-btn">browser connect</button>
+ </div>
+ <p class="voice-topbar-hint" id="voice-message" role="status" aria-live="polite" hidden></p>
+ </section>
+ {% endif %}
+
{% if config.ui.banner_url %}
<img src="{{ safe_url(config.ui.banner_url) }}" alt="Header banner" class="banner">
{% endif %}
<div class="content-wrapper">
+ {% if voice_available %}<p class="voice-notice" id="voice-notice" role="status" aria-live="polite" hidden></p>{% endif %}
+
<section id="ajax-container" aria-live="polite">
{{ html_fragment|safe }}
</section>
@@ -300,14 +377,93 @@ PAGE_TEMPLATE = """
<a href="ts3server://{{ config.server.ts_domain }}" class="btn-primary">{{ config.buttons.join }}</a>
<p class="help-text">{{ config.buttons.download_text }} <a href="{{ config.buttons.download_url }}" target="_blank" rel="noopener">{{ config.buttons.download_link }}</a></p>
-
<footer class="credits">
{{ config.footer.text }} <a href="{{ config.footer.author_url }}" target="_blank" rel="noopener">{{ config.footer.author }}</a> — Refresh scan: <span id="timer-text">{{ config.server.cache_time }}</span>s
</footer>
</div>
</main>
+{% if voice_available %}
+<dialog class="voice-dialog" id="voice-dialog" aria-labelledby="voice-dialog-title">
+ <form class="voice-dialog-form" id="voice-join-form" autocomplete="on">
+ <header class="voice-dialog-header">
+ <h2 id="voice-dialog-title">Browser connect</h2>
+ <span class="voice-dialog-target" id="voice-dialog-target">default channel</span>
+ </header>
+ <div class="voice-dialog-body">
+ <label class="voice-field">
+ <span class="voice-field-tag">[ nickname ]</span>
+ <input id="voice-nickname" name="nickname" minlength="3" maxlength="30" required autocomplete="nickname" spellcheck="false" placeholder="enter name...">
+ </label>
+ <label class="voice-field">
+ <span class="voice-field-tag">[ server password ]</span>
+ <input id="voice-password" name="password" type="password" required autocomplete="current-password" placeholder="enter password...">
+ </label>
+ <p class="voice-dialog-message" id="voice-dialog-message" role="status" aria-live="polite" hidden></p>
+ </div>
+ <footer class="voice-dialog-actions">
+ <button type="button" class="btn-voice" id="voice-cancel">cancel</button>
+ <button type="submit" class="btn-primary voice-submit" id="voice-submit">connect</button>
+ </footer>
+ </form>
+</dialog>
+
+<section class="voice-bar" id="voice-bar" aria-label="Voice controls" hidden>
+ <div class="voice-meter" aria-hidden="true">
+ <div class="voice-meter-fill" id="voice-meter"></div>
+ <div class="voice-meter-threshold" id="voice-threshold"></div>
+ </div>
+ <div class="voice-settings" id="voice-settings" hidden>
+ <div class="voice-setting">
+ <span class="stat-label">Input mode</span>
+ <button type="button" class="btn-voice" id="voice-mode">voice activity</button>
+ </div>
+ <label class="voice-setting">
+ <span class="stat-label">Mic sensitivity</span>
+ <input type="range" id="voice-sensitivity" min="0" max="100" value="45">
+ </label>
+ <label class="voice-setting">
+ <span class="stat-label">Volume</span>
+ <input type="range" id="voice-volume" min="0" max="150" value="100">
+ </label>
+ </div>
+ <p class="voice-bar-message" id="voice-bar-message" role="status" aria-live="polite" hidden></p>
+ <div class="voice-bar-main">
+ <div class="voice-bar-info">
+ <span class="voice-live-badge"><span class="pulse-dot"></span>Live</span>
+ <span class="voice-where"><span id="voice-channel">-</span><span class="voice-as">as</span><span id="voice-self">-</span></span>
+ </div>
+ <div class="voice-buttons">
+ <button type="button" class="btn-voice btn-voice-talk" id="voice-ptt" hidden>talk (V)</button>
+ <button type="button" class="btn-voice" id="voice-mute" aria-pressed="false">mute</button>
+ <button type="button" class="btn-voice" id="voice-deafen" aria-pressed="false">deafen</button>
+ <button type="button" class="btn-voice" id="voice-settings-toggle" aria-expanded="false" aria-controls="voice-settings">settings</button>
+ <button type="button" class="btn-voice btn-voice-danger" id="voice-leave">leave</button>
+ </div>
+ </div>
+</section>
+<script id="voice-config" type="application/json">{{ voice_public|tojson }}</script>
+{% endif %}
+
<script>
+ // Reloads the server-rendered channel list. Skipped while the web voice
+ // client renders it live; voice.js calls it again after leaving.
+ window.refreshDashboard = function () {
+ const container = document.getElementById('ajax-container');
+ if (!container || document.body.classList.contains('voice-live')) return Promise.resolve();
+ container.style.opacity = '0.5';
+ return fetch(window.location.href.split('?')[0] + '?ajax=1', { cache: 'no-store', headers: {'X-Requested-With': 'XMLHttpRequest'} })
+ .then(r => {
+ if (!r.ok) throw new Error("HTTP error " + r.status);
+ return r.text();
+ })
+ .then(html => {
+ if (!document.body.classList.contains('voice-live')) container.innerHTML = html;
+ })
+ .catch(err => console.error("Refresh failed:", err))
+ .finally(() => { container.style.opacity = '1'; });
+ };
+
{% if is_online %}
document.addEventListener("DOMContentLoaded", () => {
let timeLeft = {{ config.server.cache_time|int }};
@@ -315,7 +471,6 @@ PAGE_TEMPLATE = """
const progressBar = document.getElementById('refresh-bar');
const timerText = document.getElementById('timer-text');
- const container = document.getElementById('ajax-container');
function resetProgressBar() {
progressBar.style.transition = 'none';
@@ -328,36 +483,26 @@ PAGE_TEMPLATE = """
resetProgressBar();
setInterval(() => {
+ if (document.body.classList.contains('voice-live')) {
+ timerText.innerText = 'live';
+ timeLeft = totalTime;
+ return;
+ }
timeLeft--;
timerText.innerText = timeLeft;
if (timeLeft <= 0) {
- container.style.opacity = '0.5';
- fetch(window.location.href.split('?')[0] + '?ajax=1', { cache: 'no-store', headers: {'X-Requested-With': 'XMLHttpRequest'} })
- .then(r => {
- if (!r.ok) throw new Error("HTTP error " + r.status);
- return r.text();
- })
- .then(html => {
- container.innerHTML = html;
- container.style.opacity = '1';
-
- timeLeft = totalTime;
- timerText.innerText = timeLeft;
-
- resetProgressBar();
- })
- .catch(err => {
- console.error("Refresh failed:", err);
- container.style.opacity = '1';
- timeLeft = totalTime;
- resetProgressBar();
- });
+ timeLeft = totalTime;
+ window.refreshDashboard().finally(() => {
+ timerText.innerText = timeLeft;
+ resetProgressBar();
+ });
}
}, 1000);
});
{% endif %}
</script>
+{% if voice_available %}<script src="/static/voice.js" defer></script>{% endif %}
</body>
</html>
"""
@@ -384,6 +529,7 @@ def static_asset(filename):
if (
path.is_file()
and BASE_DIR in path.parents
+ and path.relative_to(BASE_DIR).parts[0] not in PRIVATE_DIRS
and path.suffix.lower() in ALLOWED_STATIC_EXTENSIONS
):
return send_from_directory(BASE_DIR, filename)
@@ -391,4 +537,9 @@ def static_asset(filename):
if __name__ == "__main__":
- app.run(host="0.0.0.0", port=5000)
+ # run.py sets DASHBOARD_HOST/DASHBOARD_LISTEN_PORT (internal port when the
+ # voice gateway fronts the app). Run directly, the dashboard serves alone.
+ app.run(
+ host=os.environ.get("DASHBOARD_HOST", CONFIG["web"]["host"]),
+ port=int(os.environ.get("DASHBOARD_LISTEN_PORT", CONFIG["web"]["port"])),
+ )
diff --git a/cache.json b/cache.json
index c2336bd..fee0192 100644
--- a/cache.json
+++ b/cache.json
@@ -1 +1 @@
-{"channels": [{"channel_name": "[Cspacer]INFORMATION", "channel_needed_subscribe_power": "0", "channel_order": "0", "cid": "13", "pid": "0", "total_clients": "0"}, {"channel_name": "[Cspacer]GAME SERVERS", "channel_needed_subscribe_power": "0", "channel_order": "13", "cid": "17", "pid": "0", "total_clients": "0"}, {"channel_name": "[Cspacer]\ud83d\udd07 AFK NPCs \ud83d\udd07", "channel_needed_subscribe_power": "0", "channel_order": "17", "cid": "4", "pid": "0", "total_clients": "1"}, {"channel_name": "General", "channel_needed_subscribe_power": "0", "channel_order": "4", "cid": "1", "pid": "0", "total_clients": "0"}, {"channel_name": "CRASHING OUT", "channel_needed_subscribe_power": "0", "channel_order": "1", "cid": "9", "pid": "0", "total_clients": "0"}, {"channel_name": "Wonhee HQ", "channel_needed_subscribe_power": "0", "channel_order": "9", "cid": "10", "pid": "0", "total_clients": "1"}], "clients": [{"cid": "4", "clid": "71", "client_channel_group_id": "8", "client_channel_group_inherited_channel_id": "4", "client_database_id": "1", "client_nickname": "serveradmin", "client_servergroups": "2", "client_type": "1"}, {"cid": "10", "clid": "1", "client_channel_group_id": "8", "client_channel_group_inherited_channel_id": "10", "client_database_id": "29", "client_nickname": "The Real Wonhee", "client_servergroups": "8", "client_type": "0"}]} \ No newline at end of file
+{"channels": [{"channel_name": "[Cspacer]INFORMATION", "channel_needed_subscribe_power": "0", "channel_order": "0", "cid": "13", "pid": "0", "total_clients": "0"}, {"channel_name": "[Cspacer]GAME SERVERS", "channel_needed_subscribe_power": "0", "channel_order": "13", "cid": "17", "pid": "0", "total_clients": "0"}, {"channel_name": "[Cspacer]\ud83d\udd07 AFK NPCs \ud83d\udd07", "channel_needed_subscribe_power": "0", "channel_order": "17", "cid": "4", "pid": "0", "total_clients": "1"}, {"channel_name": "General", "channel_needed_subscribe_power": "0", "channel_order": "4", "cid": "1", "pid": "0", "total_clients": "0"}, {"channel_name": "CRASHING OUT", "channel_needed_subscribe_power": "0", "channel_order": "1", "cid": "9", "pid": "0", "total_clients": "0"}, {"channel_name": "Wonhee HQ", "channel_needed_subscribe_power": "0", "channel_order": "9", "cid": "10", "pid": "0", "total_clients": "1"}], "clients": [{"cid": "4", "clid": "1433", "client_channel_group_id": "8", "client_channel_group_inherited_channel_id": "4", "client_database_id": "1", "client_nickname": "serveradmin", "client_servergroups": "2", "client_type": "1"}, {"cid": "10", "clid": "104", "client_channel_group_id": "5", "client_channel_group_inherited_channel_id": "10", "client_database_id": "29", "client_nickname": "The Real Wonhee", "client_servergroups": "8", "client_type": "0", "afk_text": "", "is_afk": false, "is_streaming": false, "stream_text": ""}]} \ No newline at end of file
diff --git a/config.py b/config.py
index 4d774a2..96ef08a 100644
--- a/config.py
+++ b/config.py
@@ -1,78 +1,124 @@
-"""TeamSpeak Web Explorer configuration."""
+"""TeamSpeak Web Explorer configuration.
+Settings live in ``config.toml`` (shared with the Node voice gateway in
+``gateway/``) and secrets live in ``.env``. Environment variables override the
+TOML values, using the same names the gateway reads (see ``ENV_OVERRIDES``).
+"""
+
+import os
+import tomllib
from pathlib import Path
+
from dotenv import load_dotenv
-import os
-load_dotenv()
BASE_DIR = Path(__file__).resolve().parent
+CONFIG_FILE = Path(os.environ.get("TS6_CONFIG_FILE") or BASE_DIR / "config.toml")
+
+load_dotenv(BASE_DIR / ".env")
+
+# (section, key, ENV_VAR, type). Keep in sync with gateway/src/config.js.
+ENV_OVERRIDES = [
+ ("web", "host", "WEB_HOST", str),
+ ("web", "port", "WEB_PORT", int),
+ ("web", "dashboard_port", "DASHBOARD_PORT", int),
+ ("server", "host", "TS_HOST", str),
+ ("server", "webquery_port", "TS_WEBQUERY_PORT", int),
+ ("server", "server_id", "TS_SERVER_ID", str),
+ ("server", "voice_port", "TS_VOICE_PORT", int),
+ ("server", "voice_port", "TS_PORT", int), # legacy name from the old .env.template
+ ("server", "ts_domain", "TS_DOMAIN", str),
+ ("voice", "enabled", "VOICE_ENABLED", bool),
+ ("voice", "trust_proxy", "VOICE_TRUST_PROXY", bool),
+]
+
+
+def _env_value(raw, kind):
+ if kind is bool:
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
+ return kind(raw.strip())
+
+
+def _load_toml():
+ try:
+ with CONFIG_FILE.open("rb") as fh:
+ return tomllib.load(fh)
+ except FileNotFoundError:
+ print(f"[config] {CONFIG_FILE} not found, using defaults")
+ return {}
+
+
+def _build_config():
+ raw = _load_toml()
+ sections = {
+ name: dict(raw.get(name) or {})
+ for name in (
+ "web", "server", "voice", "ui", "buttons", "external_links", "footer",
+ "channel_banners", "channel_icons", "generic_images", "role_icons",
+ )
+ }
+
+ applied = set()
+ for section, key, env_name, kind in ENV_OVERRIDES:
+ value = os.environ.get(env_name)
+ if value is None or value.strip() == "" or (section, key) in applied:
+ continue
+ try:
+ sections[section][key] = _env_value(value, kind)
+ applied.add((section, key))
+ except ValueError:
+ print(f"[config] ignoring invalid {env_name}={value!r}")
+
+ web = sections["web"]
+ web.setdefault("host", "0.0.0.0")
+ web.setdefault("port", 5000)
+ web.setdefault("dashboard_port", 5001)
+
+ server = sections["server"]
+ server.setdefault("host", "")
+ server["port"] = server.get("webquery_port", 8443) # name used by the WebQuery fetcher
+ server["server_id"] = str(server.get("server_id", 1))
+ server.setdefault("voice_port", 9987)
+ server["ts_domain"] = server.get("ts_domain") or server["host"]
+ server.setdefault("cache_time", 30)
+ server["cache_file"] = BASE_DIR / server.get("cache_file", "cache.json")
+ server.setdefault("afk_status_max_clients", 3)
+ server["api_key"] = os.environ.get("TS_API_KEY", "")
-CONFIG = {
- # Server connection settings
- "server": {
- "host": os.environ.get("TS_HOST", ""), # Server IP or hostname, e.g. 192.168.1.50
- "port": os.environ.get("TS_WEBQUERY_PORT", "8443"), # HTTP WebQuery port (TS6)
- "api_key": os.environ.get("TS_API_KEY", ""),
- "server_id": os.environ.get("TS_SERVER_ID", "1"),
- "ts_domain": os.environ.get("TS_HOST", ""), # Used by the connect button
- "cache_time": 30, # Refresh interval in seconds (anti-spam)
- "cache_file": BASE_DIR / "cache.json",
- "afk_status_max_clients": 3, # Skip per-client clientinfo (AFK/streaming) calls when client count exceeds this (due to potential rate limits)
- },
+ voice = sections["voice"]
+ voice.setdefault("enabled", True)
- # Main design and copy
- "ui": {
- "title": "",
- "subtitle": "",
- "background_color": "#07111f", # Main background color
- "panel_color": "#101b2d", # Main panel and channel block color
- "accent_color": "#2589ff", # Buttons, highlights, and refresh timer
- "text_color": "#e8f2ff",
- "banner_url": "banner.jpg", # Leave empty ('') to hide the header banner
- "enable_stars": True, # Set to True to enable a subtle animated space background
- },
+ ui = sections["ui"]
+ for key, default in {
+ "title": "", "subtitle": "", "background_color": "#07111f",
+ "panel_color": "#101b2d", "accent_color": "#2589ff", "text_color": "#e8f2ff",
+ "banner_url": "", "enable_stars": False,
+ }.items():
+ ui.setdefault(key, default)
- # Interface buttons and links
- "buttons": {
- "join": "Connect to TeamSpeak",
+ buttons = sections["buttons"]
+ for key, default in {
+ "join": "Connect in TeamSpeak", "web_join": "Join from browser",
"download_text": "Teamspeak is not installed yet?",
"download_link": "Download Teamspeak",
- "download_url": "https://www.teamspeak.com/en/downloads/", # Official download link
- },
+ "download_url": "https://www.teamspeak.com/en/downloads/",
+ }.items():
+ buttons.setdefault(key, default)
- # Useful links. Add or remove as many rows as you want.
- "external_links": {
- },
+ footer = sections["footer"]
+ for key in ("text", "author", "author_url"):
+ footer.setdefault(key, "")
- # Footer
- "footer": {
- "text": "another moekyun service",
- "author": "",
- "author_url": "",
- },
+ generic = sections["generic_images"]
+ generic.setdefault("background", "")
+ generic.setdefault("icon", "")
- # Channel customization. Exact channel name => image filename.
- "channel_banners": {
- "INFORMATION": "info.png",
- "GAME SERVERS": "game.jpeg",
- "General": "general.jpg",
- "CRASHING OUT": "crash.png",
- "Wonhee HQ": "illit.jpg",
- },
+ sections["role_icons"] = {str(k): v for k, v in sections["role_icons"].items()}
+ return sections
- "channel_icons": {
- # "Lobby": "lobby_icon.png",
- # "General": "general_icon.png",
- },
- "generic_images": {
- "background": "", # Default image applied to unknown channels
- "icon": "", # Default icon applied to unknown channels
- },
+CONFIG = _build_config()
- # Server groups / roles: 'GROUP_ID' => 'Image'
- "role_icons": {
- # "6": "admin.png",
- # "9": "vip.png",
- },
-}
+# Web voice is only offered when enabled AND a server password is configured:
+# the gateway refuses every join without one, so nobody can join without it.
+# The password itself is intentionally kept out of CONFIG (which templates see).
+VOICE_AVAILABLE = bool(CONFIG["voice"].get("enabled")) and bool(os.environ.get("TS_SERVER_PASSWORD"))
diff --git a/config.toml b/config.toml
new file mode 100644
index 0000000..e314aed
--- /dev/null
+++ b/config.toml
@@ -0,0 +1,93 @@
+# ---------------------------------------------------------------------------
+# Shared configuration for the dashboard (Flask, config.py) and the browser
+# voice gateway (Node, gateway/src/config.js). Both read this one file.
+#
+# Secrets do NOT belong here: put them in `.env` (see .env.template).
+# Any environment variable listed next to a setting overrides the value here.
+# ---------------------------------------------------------------------------
+
+# Where the app listens. When voice is enabled, the voice gateway owns the
+# public port and forwards every non-voice request to the dashboard, which
+# then listens on 127.0.0.1:dashboard_port only. One port to expose/proxy.
+[web]
+host = "0.0.0.0" # WEB_HOST
+port = 5000 # WEB_PORT - public port
+dashboard_port = 5001 # DASHBOARD_PORT - internal Flask port (voice enabled only)
+
+# TeamSpeak server connection settings
+[server]
+host = "" # TS_HOST - server IP or hostname, e.g. 192.168.1.50
+webquery_port = 8443 # TS_WEBQUERY_PORT - HTTP WebQuery port (TS6)
+server_id = 1 # TS_SERVER_ID - virtual server id
+voice_port = 9987 # TS_VOICE_PORT - UDP voice port the web voice gateway connects to
+ts_domain = "" # TS_DOMAIN - address used by the "Connect" button; empty = host
+cache_time = 30 # Refresh interval in seconds (anti-spam)
+cache_file = "cache.json" # Relative to the repository root
+afk_status_max_clients = 3 # Skip per-client clientinfo (AFK/streaming) calls when client count exceeds this (due to potential rate limits)
+# TS_API_KEY (.env) - WebQuery API key
+# TS_SERVER_PASSWORD (.env) - server password; required for web voice
+
+# Browser voice (join the server from the dashboard).
+# Voice is only offered when `enabled = true` AND TS_SERVER_PASSWORD is set:
+# visitors must type the server password, which the gateway checks before
+# it opens any TeamSpeak connection (the TeamSpeak server checks it again).
+[voice]
+enabled = true # VOICE_ENABLED
+max_sessions = 8 # Concurrent browser voice sessions (keep low on small SBCs)
+max_sessions_per_ip = 2 # Concurrent sessions from one IP address
+max_failed_logins = 5 # Wrong passwords per IP before a temporary lockout
+lockout_minutes = 10 # Lockout duration after too many wrong passwords
+nickname_prefix = "" # Prepended to web users' nicknames, e.g. "[web] "
+default_channel = "" # Channel name (or path "Parent/Child") to join; empty = server default
+identity_security_level = 8 # TeamSpeak identity security level for new web users
+trust_proxy = false # Use X-Forwarded-For for per-IP limits (only behind your own reverse proxy)
+allowed_origins = [] # Extra allowed Origins for /ws/voice, e.g. ["https://ts.example.com"]; same-host is always allowed
+
+# Main design and copy
+[ui]
+title = ""
+subtitle = ""
+background_color = "#07111f" # Main background color
+panel_color = "#101b2d" # Main panel and channel block color
+accent_color = "#2589ff" # Buttons, highlights, and refresh timer
+text_color = "#e8f2ff"
+banner_url = "banner.jpg" # Leave empty ("") to hide the header banner
+enable_stars = true # Set to true to enable a subtle animated space background
+
+# Interface buttons and links
+[buttons]
+join = "Connect in TeamSpeak"
+web_join = "Join from browser"
+download_text = "Teamspeak is not installed yet?"
+download_link = "Download Teamspeak"
+download_url = "https://www.teamspeak.com/en/downloads/" # Official download link
+
+# Useful links. Add or remove as many rows as you want: "Name" = "https://..."
+[external_links]
+
+# Footer
+[footer]
+text = "another moekyun service"
+author = ""
+author_url = ""
+
+# Channel customization. Exact channel name => image filename.
+[channel_banners]
+"INFORMATION" = "info.png"
+"GAME SERVERS" = "game.jpeg"
+"General" = "general.jpg"
+"CRASHING OUT" = "crash.png"
+"Wonhee HQ" = "illit.jpg"
+
+[channel_icons]
+# "Lobby" = "lobby_icon.png"
+# "General" = "general_icon.png"
+
+[generic_images]
+background = "" # Default image applied to unknown channels
+icon = "" # Default icon applied to unknown channels
+
+# Server groups / roles: "GROUP_ID" = "Image"
+[role_icons]
+# "6" = "admin.png"
+# "9" = "vip.png"
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..ecc2380
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,25 @@
+services:
+ ts6-webview:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: ts6-webview
+ restart: unless-stopped
+ env_file:
+ - .env
+ environment:
+ WEB_HOST: "0.0.0.0"
+ WEB_PORT: "5000"
+ # Set to true when running behind Nginx / Caddy on the same host so
+ # per-IP rate limits use X-Forwarded-For instead of the proxy IP.
+ VOICE_TRUST_PROXY: "true"
+ ports:
+ - "127.0.0.1:5000:5000"
+ volumes:
+ - ./config.toml:/app/config.toml:ro
+ - ./static:/app/static:ro
+ # Optional resource cap for small arm64 SBCs
+ deploy:
+ resources:
+ limits:
+ memory: 256M
diff --git a/gateway/package-lock.json b/gateway/package-lock.json
new file mode 100644
index 0000000..baf39c0
--- /dev/null
+++ b/gateway/package-lock.json
@@ -0,0 +1,107 @@
+{
+ "name": "ts6-voice-gateway",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "ts6-voice-gateway",
+ "version": "0.1.0",
+ "dependencies": {
+ "@honeybbq/teamspeak": "1.0.0-beta.19",
+ "opusscript": "^0.1.1",
+ "smol-toml": "^1.3.1",
+ "ws": "^8.20.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@honeybbq/teamspeak": {
+ "version": "1.0.0-beta.19",
+ "resolved": "https://registry.npmjs.org/@honeybbq/teamspeak/-/teamspeak-1.0.0-beta.19.tgz",
+ "integrity": "sha512-5srWgwbcMx1j4JpwYonxKkOYla8hx7ojX+dYOC0hiv5NCjozMRsy8aIYzwSpzHr2y3q/aKljnPyM426DTRekrA==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/curves": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=22.12"
+ },
+ "peerDependencies": {
+ "ssh2": "^1.17.0"
+ },
+ "peerDependenciesMeta": {
+ "ssh2": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@noble/curves": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz",
+ "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "2.4.0"
+ },
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/hashes": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
+ "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/opusscript": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.1.1.tgz",
+ "integrity": "sha512-mL0fZZOUnXdZ78woRXp18lApwpp0lF5tozJOD1Wut0dgrA9WuQTgSels/CSmFleaAZrJi/nci5KOVtbuxeWoQA==",
+ "license": "MIT"
+ },
+ "node_modules/smol-toml": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.1.tgz",
+ "integrity": "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/cyyynthia"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.22.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
+ "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/gateway/package.json b/gateway/package.json
new file mode 100644
index 0000000..8c8dba4
--- /dev/null
+++ b/gateway/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "ts6-voice-gateway",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Browser-to-TeamSpeak voice gateway for the TeamSpeak 6 web dashboard",
+ "type": "module",
+ "main": "src/index.js",
+ "engines": {
+ "node": ">=22.12.0"
+ },
+ "scripts": {
+ "start": "node src/index.js",
+ "test": "node --test \"test/*.test.js\""
+ },
+ "dependencies": {
+ "@honeybbq/teamspeak": "1.0.0-beta.19",
+ "opusscript": "^0.1.1",
+ "smol-toml": "^1.3.1",
+ "ws": "^8.20.0"
+ }
+}
diff --git a/gateway/src/auth.js b/gateway/src/auth.js
new file mode 100644
index 0000000..288de90
--- /dev/null
+++ b/gateway/src/auth.js
@@ -0,0 +1,83 @@
+// Access control for browser voice: server-password check and brute-force limits.
+import { createHash, timingSafeEqual } from "node:crypto";
+
+const digest = (value) => createHash("sha256").update(String(value), "utf8").digest();
+
+/**
+ * Constant-time comparison of the typed password against the configured one.
+ * An unset/empty configured password never matches: voice requires a password.
+ */
+export function passwordMatches(input, expected) {
+ if (typeof input !== "string" || typeof expected !== "string" || expected.length === 0) return false;
+ if (input.length > 512) return false;
+ return timingSafeEqual(digest(input), digest(expected));
+}
+
+/** Per-IP failed-login tracking with a temporary lockout. */
+export class LoginLimiter {
+ constructor({ maxFailures = 5, lockoutMs = 10 * 60_000, maxTracked = 4096 } = {}, clock = Date.now) {
+ this.maxFailures = maxFailures;
+ this.lockoutMs = lockoutMs;
+ this.maxTracked = maxTracked;
+ this.clock = clock;
+ this.peers = new Map(); // ip -> { failures, firstAt, lockedUntil }
+ }
+
+ /** Milliseconds the peer is still locked out for (0 = allowed). */
+ lockedFor(ip) {
+ const entry = this.peers.get(ip);
+ if (!entry) return 0;
+ const now = this.clock();
+ if (entry.lockedUntil > now) return entry.lockedUntil - now;
+ if (now - entry.firstAt > this.lockoutMs) this.peers.delete(ip);
+ return 0;
+ }
+
+ recordFailure(ip) {
+ const now = this.clock();
+ let entry = this.peers.get(ip);
+ if (!entry || now - entry.firstAt > this.lockoutMs) {
+ this.prune(now);
+ entry = { failures: 0, firstAt: now, lockedUntil: 0 };
+ this.peers.set(ip, entry);
+ }
+ entry.failures += 1;
+ if (entry.failures >= this.maxFailures) entry.lockedUntil = now + this.lockoutMs;
+ }
+
+ recordSuccess(ip) {
+ this.peers.delete(ip);
+ }
+
+ prune(now) {
+ for (const [ip, entry] of this.peers) {
+ if (entry.lockedUntil <= now && now - entry.firstAt > this.lockoutMs) this.peers.delete(ip);
+ }
+ while (this.peers.size >= this.maxTracked) {
+ const oldest = this.peers.keys().next().value;
+ if (oldest === undefined) break;
+ this.peers.delete(oldest);
+ }
+ }
+}
+
+/** Same-host Origin check (plus configured extra origins) for the voice WebSocket. */
+export function originAllowed(origin, host, allowedOrigins = []) {
+ if (!origin) return false; // browsers always send Origin on WebSocket upgrades
+ const normalized = origin.replace(/\/+$/, "").toLowerCase();
+ if (allowedOrigins.includes("*") || allowedOrigins.includes(normalized)) return true;
+ try {
+ return new URL(normalized).host === String(host ?? "").toLowerCase();
+ } catch {
+ return false;
+ }
+}
+
+/** Trim, drop control characters and enforce TeamSpeak's 3..30 character nickname rule. */
+export function sanitizeNickname(input, prefix = "") {
+ if (typeof input !== "string") return null;
+ const cleaned = input.replace(/[\u0000-\u001f\u007f]/g, "").replace(/\s+/g, " ").trim();
+ if (!cleaned) return null;
+ const full = `${prefix}${cleaned}`.slice(0, 30).trim();
+ return [...full].length >= 3 ? full : null;
+}
diff --git a/gateway/src/config.js b/gateway/src/config.js
new file mode 100644
index 0000000..33cad84
--- /dev/null
+++ b/gateway/src/config.js
@@ -0,0 +1,97 @@
+// Loads the same config.toml + .env the Flask dashboard uses (see config.py).
+import { existsSync, readFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { parse as parseToml } from "smol-toml";
+
+export const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
+
+// [section, key, ENV_VAR, type]. Keep in sync with ENV_OVERRIDES in config.py.
+const ENV_OVERRIDES = [
+ ["web", "host", "WEB_HOST", "string"],
+ ["web", "port", "WEB_PORT", "int"],
+ ["web", "dashboard_port", "DASHBOARD_PORT", "int"],
+ ["server", "host", "TS_HOST", "string"],
+ ["server", "webquery_port", "TS_WEBQUERY_PORT", "int"],
+ ["server", "server_id", "TS_SERVER_ID", "string"],
+ ["server", "voice_port", "TS_VOICE_PORT", "int"],
+ ["server", "voice_port", "TS_PORT", "int"], // legacy name from the old .env.template
+ ["server", "ts_domain", "TS_DOMAIN", "string"],
+ ["voice", "enabled", "VOICE_ENABLED", "bool"],
+ ["voice", "trust_proxy", "VOICE_TRUST_PROXY", "bool"],
+];
+
+function envValue(raw, kind) {
+ const value = raw.trim();
+ if (kind === "bool") return ["1", "true", "yes", "on"].includes(value.toLowerCase());
+ if (kind === "int") {
+ if (!/^\d+$/.test(value)) throw new Error("not an integer");
+ return Number(value);
+ }
+ return value;
+}
+
+function toInt(value, fallback, min = 0, max = Number.MAX_SAFE_INTEGER) {
+ const n = Number(value);
+ return Number.isInteger(n) && n >= min && n <= max ? n : fallback;
+}
+
+export function loadConfig(env = process.env) {
+ const envFile = resolve(ROOT_DIR, ".env");
+ // Like python-dotenv's load_dotenv(): existing variables are not overridden.
+ if (env === process.env && existsSync(envFile)) process.loadEnvFile(envFile);
+
+ const configFile = env.TS6_CONFIG_FILE || resolve(ROOT_DIR, "config.toml");
+ let raw = {};
+ if (existsSync(configFile)) raw = parseToml(readFileSync(configFile, "utf8"));
+ else console.warn(`[config] ${configFile} not found, using defaults`);
+
+ const section = (name) => ({ ...(raw[name] ?? {}) });
+ const web = section("web");
+ const server = section("server");
+ const voice = section("voice");
+
+ const sections = { web, server, voice };
+ const applied = new Set();
+ for (const [name, key, envName, kind] of ENV_OVERRIDES) {
+ const value = env[envName];
+ const id = `${name}.${key}`;
+ if (value === undefined || value.trim() === "" || applied.has(id)) continue;
+ try {
+ sections[name][key] = envValue(value, kind);
+ applied.add(id);
+ } catch {
+ console.warn(`[config] ignoring invalid ${envName}=${JSON.stringify(value)}`);
+ }
+ }
+
+ const allowedOrigins = Array.isArray(voice.allowed_origins)
+ ? voice.allowed_origins.filter((o) => typeof o === "string" && o).map((o) => o.replace(/\/+$/, "").toLowerCase())
+ : [];
+
+ return {
+ web: {
+ host: String(web.host ?? "0.0.0.0"),
+ port: toInt(web.port, 5000, 1, 65535),
+ dashboardPort: toInt(web.dashboard_port, 5001, 1, 65535),
+ },
+ server: {
+ host: String(server.host ?? "").trim(),
+ voicePort: toInt(server.voice_port, 9987, 1, 65535),
+ // Secret: only ever read from the environment / .env.
+ password: env.TS_SERVER_PASSWORD ?? "",
+ },
+ voice: {
+ enabled: voice.enabled !== false,
+ maxSessions: toInt(voice.max_sessions, 8, 1, 1000),
+ maxSessionsPerIp: toInt(voice.max_sessions_per_ip, 2, 1, 1000),
+ maxFailedLogins: toInt(voice.max_failed_logins, 5, 1, 1000),
+ lockoutMs: toInt(voice.lockout_minutes, 10, 1, 24 * 60) * 60_000,
+ nicknamePrefix: String(voice.nickname_prefix ?? ""),
+ defaultChannel: String(voice.default_channel ?? ""),
+ identitySecurityLevel: toInt(voice.identity_security_level, 8, 0, 40),
+ trustProxy: voice.trust_proxy === true,
+ allowedOrigins,
+ },
+ };
+}
diff --git a/gateway/src/index.js b/gateway/src/index.js
new file mode 100644
index 0000000..28beec5
--- /dev/null
+++ b/gateway/src/index.js
@@ -0,0 +1,39 @@
+import { loadConfig } from "./config.js";
+import { createGatewayServer } from "./server.js";
+import { opusBackendName } from "./opus.js";
+
+const config = loadConfig();
+const log = console;
+
+const { server, shutdown, voiceAvailable } = createGatewayServer(config, log);
+
+server.listen(config.web.port, config.web.host, () => {
+ log.info(`[gateway] listening on http://${config.web.host}:${config.web.port} -> dashboard 127.0.0.1:${config.web.dashboardPort}`);
+ if (voiceAvailable()) {
+ log.info(`[gateway] web voice enabled for ${config.server.host}:${config.server.voicePort} (opus: ${opusBackendName()})`);
+ } else if (!config.server.password) {
+ log.warn("[gateway] web voice disabled: set TS_SERVER_PASSWORD in .env (visitors must know it to join)");
+ } else if (!config.server.host) {
+ log.warn("[gateway] web voice disabled: set TS_HOST");
+ } else {
+ log.warn("[gateway] web voice disabled in config.toml ([voice] enabled = false)");
+ }
+});
+
+server.on("error", (error) => {
+ log.error(`[gateway] ${error.message}`);
+ process.exit(1);
+});
+
+let stopping = false;
+async function stop(signal) {
+ if (stopping) return;
+ stopping = true;
+ log.info(`[gateway] ${signal} received, disconnecting voice sessions`);
+ const force = setTimeout(() => process.exit(0), 3000);
+ force.unref();
+ await shutdown();
+ process.exit(0);
+}
+process.on("SIGINT", () => void stop("SIGINT"));
+process.on("SIGTERM", () => void stop("SIGTERM"));
diff --git a/gateway/src/opus.js b/gateway/src/opus.js
new file mode 100644
index 0000000..b1805d6
--- /dev/null
+++ b/gateway/src/opus.js
@@ -0,0 +1,59 @@
+// Opus codec wrapper. Uses the native @discordjs/opus addon when it happens to
+// be installed (faster), otherwise opusscript (WASM): no compiler needed, so it
+// installs cleanly on arm64 SBCs.
+import { createRequire } from "node:module";
+
+const require = createRequire(import.meta.url);
+
+export const SAMPLE_RATE = 48_000;
+export const FRAME_SAMPLES = 960; // 20 ms @ 48 kHz
+export const FRAME_BYTES = FRAME_SAMPLES * 2; // mono Int16
+
+let backend = null;
+
+function loadBackend() {
+ if (backend) return backend;
+ try {
+ const { OpusEncoder } = require("@discordjs/opus");
+ backend = {
+ name: "@discordjs/opus (native)",
+ create(channels) {
+ const codec = new OpusEncoder(SAMPLE_RATE, channels);
+ return {
+ encode: (pcm) => codec.encode(pcm),
+ decode: (data) => codec.decode(data),
+ setBitrate: (bps) => codec.setBitrate(bps),
+ dispose() {},
+ };
+ },
+ };
+ } catch {
+ const OpusScript = require("opusscript");
+ backend = {
+ name: "opusscript (wasm)",
+ create(channels) {
+ const codec = new OpusScript(SAMPLE_RATE, channels, OpusScript.Application.VOIP);
+ return {
+ encode: (pcm) => Buffer.from(codec.encode(pcm, pcm.length / (2 * channels))),
+ decode: (data) => Buffer.from(codec.decode(data)),
+ setBitrate: (bps) => codec.setBitrate(bps),
+ dispose: () => codec.delete(),
+ };
+ },
+ };
+ }
+ return backend;
+}
+
+export function opusBackendName() {
+ return loadBackend().name;
+}
+
+/** Mono 48 kHz Opus codec (encoder + decoder state). */
+export function createOpusCodec({ bitrate } = {}) {
+ const codec = loadBackend().create(1);
+ if (bitrate) {
+ try { codec.setBitrate(bitrate); } catch { /* keep the library default */ }
+ }
+ return codec;
+}
diff --git a/gateway/src/server.js b/gateway/src/server.js
new file mode 100644
index 0000000..b4e716e
--- /dev/null
+++ b/gateway/src/server.js
@@ -0,0 +1,128 @@
+// Public HTTP front: serves /ws/voice itself and reverse-proxies everything
+// else to the Flask dashboard, so the whole app is one origin on one port.
+import http from "node:http";
+import { WebSocketServer } from "ws";
+import { LoginLimiter, originAllowed } from "./auth.js";
+import { VoiceSession } from "./voice-session.js";
+
+const VOICE_PATH = "/ws/voice";
+const STATUS_PATH = "/voice-gateway/status";
+const HEARTBEAT_MS = 30_000;
+const HOP_BY_HOP = ["connection", "keep-alive", "proxy-connection", "proxy-authenticate", "proxy-authorization", "te", "trailer", "upgrade"];
+
+function clientIp(req, trustProxy) {
+ if (trustProxy) {
+ const forwarded = String(req.headers["x-forwarded-for"] ?? "").split(",")[0].trim();
+ if (forwarded) return forwarded;
+ }
+ return (req.socket.remoteAddress ?? "unknown").replace(/^::ffff:/, "");
+}
+
+function rejectUpgrade(socket, status, text) {
+ socket.end(`HTTP/1.1 ${status} ${text}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
+}
+
+export function createGatewayServer(config, log = console) {
+ const limiter = new LoginLimiter({ maxFailures: config.voice.maxFailedLogins, lockoutMs: config.voice.lockoutMs });
+ const sessions = new Set();
+ const perIp = new Map();
+ const upstreamAgent = new http.Agent({ keepAlive: true, maxSockets: 64 });
+ const voiceAvailable = () => config.voice.enabled && Boolean(config.server.password) && Boolean(config.server.host);
+
+ function proxyToDashboard(req, res) {
+ const headers = { ...req.headers };
+ for (const name of HOP_BY_HOP) delete headers[name];
+ const ip = clientIp(req, false);
+ headers["x-forwarded-for"] = config.voice.trustProxy && req.headers["x-forwarded-for"]
+ ? `${req.headers["x-forwarded-for"]}, ${ip}` : ip;
+ headers["x-forwarded-host"] ??= req.headers.host ?? "";
+ headers["x-forwarded-proto"] = config.voice.trustProxy && req.headers["x-forwarded-proto"]
+ ? req.headers["x-forwarded-proto"] : "http";
+
+ const upstream = http.request({
+ host: "127.0.0.1",
+ port: config.web.dashboardPort,
+ method: req.method,
+ path: req.url,
+ headers,
+ agent: upstreamAgent,
+ }, (upstreamRes) => {
+ const responseHeaders = { ...upstreamRes.headers };
+ for (const name of HOP_BY_HOP) delete responseHeaders[name];
+ res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.statusMessage, responseHeaders);
+ upstreamRes.pipe(res);
+ });
+ upstream.setTimeout(30_000, () => upstream.destroy(new Error("upstream timeout")));
+ upstream.on("error", () => {
+ if (!res.headersSent) res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
+ res.end("Dashboard is unavailable (is it still starting?)");
+ });
+ req.pipe(upstream);
+ }
+
+ const server = http.createServer((req, res) => {
+ if (req.url === STATUS_PATH) {
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
+ res.end(JSON.stringify({ available: voiceAvailable(), sessions: sessions.size, maxSessions: config.voice.maxSessions }));
+ return;
+ }
+ proxyToDashboard(req, res);
+ });
+
+ const wss = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024, perMessageDeflate: false });
+
+ server.on("upgrade", (req, socket, head) => {
+ const pathname = (req.url ?? "").split("?")[0];
+ if (pathname !== VOICE_PATH) return rejectUpgrade(socket, 404, "Not Found");
+ if (!originAllowed(req.headers.origin, req.headers["x-forwarded-host"] && config.voice.trustProxy
+ ? req.headers["x-forwarded-host"] : req.headers.host, config.voice.allowedOrigins)) {
+ log.warn(`[voice] rejected origin ${req.headers.origin ?? "(none)"}`);
+ return rejectUpgrade(socket, 403, "Forbidden");
+ }
+ if (!voiceAvailable()) return rejectUpgrade(socket, 503, "Service Unavailable");
+ const ip = clientIp(req, config.voice.trustProxy);
+ if (sessions.size >= config.voice.maxSessions) return rejectUpgrade(socket, 503, "Service Unavailable");
+ if ((perIp.get(ip) ?? 0) >= config.voice.maxSessionsPerIp) return rejectUpgrade(socket, 429, "Too Many Requests");
+
+ wss.handleUpgrade(req, socket, head, (ws) => {
+ perIp.set(ip, (perIp.get(ip) ?? 0) + 1);
+ ws.isAlive = true;
+ ws.on("pong", () => { ws.isAlive = true; });
+ let released = false;
+ const session = new VoiceSession(ws, {
+ ip,
+ config,
+ limiter,
+ log,
+ onClose() {
+ if (released) return;
+ released = true;
+ sessions.delete(session);
+ const left = (perIp.get(ip) ?? 1) - 1;
+ if (left > 0) perIp.set(ip, left); else perIp.delete(ip);
+ },
+ });
+ sessions.add(session);
+ });
+ });
+
+ const heartbeat = setInterval(() => {
+ for (const ws of wss.clients) {
+ if (!ws.isAlive) { ws.terminate(); continue; }
+ ws.isAlive = false;
+ try { ws.ping(); } catch { /* closing */ }
+ }
+ }, HEARTBEAT_MS);
+ heartbeat.unref();
+
+ async function shutdown() {
+ clearInterval(heartbeat);
+ for (const session of [...sessions]) session.close();
+ await new Promise((resolve) => setTimeout(resolve, 300)); // let TeamSpeak goodbyes go out
+ wss.close();
+ server.close();
+ upstreamAgent.destroy();
+ }
+
+ return { server, sessions, shutdown, voiceAvailable };
+}
diff --git a/gateway/src/voice-session.js b/gateway/src/voice-session.js
new file mode 100644
index 0000000..039df7b
--- /dev/null
+++ b/gateway/src/voice-session.js
@@ -0,0 +1,500 @@
+// One browser <-> TeamSpeak voice session.
+//
+// Wire protocol on /ws/voice (no WebRTC; everything rides on this WebSocket):
+// browser -> gateway JSON {type:"join", nickname, password, channelId?, channelPassword?, identity?, serverDecode?}
+// JSON {type:"move", channelId, password?} | {type:"mute", muted} | {type:"deafen", deafened}
+// {type:"chat", scope?:"channel"|"server"|"private", message, targetId?}
+// {type:"whisper", targetIds:number[], active:boolean}
+// {type:"leave"}
+// binary 1920 bytes = 20 ms of 48 kHz mono Int16LE PCM from the microphone
+// gateway -> browser JSON {type:"joined"|"state"|"chatMessage"|"whisperTargets"|"error"|"disconnected", ...}
+// binary [kind u8][clientId u16BE][payload]
+// kind 4/5 = TeamSpeak Opus (voice/music) frame, decoded by WebCodecs in the browser
+// kind 0xF0 = 48 kHz mono Int16LE PCM (gateway-side decode fallback)
+// an empty payload marks the end of that speaker's transmission
+import { WebSocket } from "ws";
+import {
+ asChannelID,
+ asClientID,
+ moveClient,
+ sendChannelMessage,
+ sendPrivateMessage,
+ sendServerMessage,
+ updateClient,
+} from "@honeybbq/teamspeak";
+import { Client, generateIdentity, parseIdentity, subscribeAllChannels } from "@honeybbq/teamspeak/client";
+import { createOpusCodec, FRAME_BYTES } from "./opus.js";
+import { passwordMatches, sanitizeNickname } from "./auth.js";
+
+const CONNECT_TIMEOUT_MS = 15_000;
+const STATE_THROTTLE_MS = 150;
+const END_OF_SPEECH_MS = 100;
+const OPUS_BITRATE = 40_000;
+const MAX_BUFFERED_OPUS_BYTES = 8 * 1024;
+const MAX_BUFFERED_PCM_BYTES = 32 * 1024;
+const MAX_SPEAKER_DECODERS = 16;
+const MAX_CHAT_LENGTH = 1024;
+const MAX_WHISPER_TARGETS = 32;
+const KIND_PCM = 0xf0;
+// Token bucket for microphone frames: 50 frames/s nominal, small burst allowance.
+const FRAME_RATE = 55;
+const FRAME_BURST = 25;
+
+const ERROR_TEXT = {
+ 1028: ["BAD_PASSWORD", "The TeamSpeak server rejected the password."],
+ 781: ["CHANNEL_PASSWORD", "Wrong channel password."],
+ 513: ["NICKNAME_IN_USE", "That nickname is already in use."],
+ 514: ["NICKNAME_INVALID", "That nickname is not allowed."],
+ 521: ["TOO_MANY_CLONES", "Too many connections from this gateway."],
+ 524: ["FLOODING", "Slow down, the server thinks you are flooding."],
+ 777: ["CHANNEL_FULL", "That channel is full."],
+ 770: ["ALREADY_IN_CHANNEL", "You are already in that channel."],
+ 2568: ["NO_PERMISSION", "You do not have permission to do that."],
+ 3329: ["BANNED", "You are banned from this server."],
+};
+
+function describeError(error) {
+ for (let e = error, depth = 0; e && depth < 5; e = e.cause, depth++) {
+ if (typeof e.code === "number" && ERROR_TEXT[e.code]) {
+ const [code, message] = ERROR_TEXT[e.code];
+ return { code, message };
+ }
+ }
+ const message = error instanceof Error ? error.message : String(error);
+ if (/abort|timeout|timed out/i.test(message)) return { code: "TIMEOUT", message: "Timed out reaching the TeamSpeak server." };
+ return { code: "TEAMSPEAK_ERROR", message: message.slice(0, 200) || "TeamSpeak error" };
+}
+
+function snapshot(client) {
+ const view = client.view;
+ return {
+ serverName: view.serverName ?? "",
+ self: { id: Number(client.clientID), channelId: String(client.channelID) },
+ channels: [...view.channels.values()].map((c) => ({
+ id: String(c.id),
+ parentId: String(c.parentID),
+ order: String(c.order),
+ name: c.name,
+ hasPassword: Boolean(c.hasPassword),
+ })),
+ clients: [...view.clients.values()].map((e) => ({
+ id: Number(e.id),
+ nickname: e.nickname,
+ channelId: String(e.channelID),
+ type: e.type,
+ away: Boolean(e.isAway),
+ awayMessage: e.awayMessage ?? "",
+ inputMuted: Boolean(e.isInputMuted),
+ outputMuted: Boolean(e.isOutputMuted),
+ serverGroups: (e.serverGroups ?? []).map(String),
+ })),
+ };
+}
+
+const CHANNEL_ID = /^\d{1,20}$/;
+
+export class VoiceSession {
+ /**
+ * @param {WebSocket} ws
+ * @param {{ip: string, config: ReturnType<import("./config.js").loadConfig>, limiter: import("./auth.js").LoginLimiter, log: Console, onClose: () => void}} ctx
+ */
+ constructor(ws, ctx) {
+ this.ws = ws;
+ this.ctx = ctx;
+ this.client = null;
+ this.state = "awaiting-join"; // -> connecting -> ready -> closed
+ this.muted = false;
+ this.deafened = false;
+ this.serverDecode = false;
+ this.encoder = null;
+ this.decoders = new Map();
+ this.whisperTargetIds = new Set();
+ this.whisperActive = false;
+ this.stateTimer = null;
+ this.eosTimer = null;
+ this.tokens = FRAME_BURST;
+ this.tokensAt = Date.now();
+ this.badFrames = 0;
+ this.kickReason = "";
+ this.unsubscribers = [];
+ this.closing = false;
+
+ this.joinTimer = setTimeout(() => this.fail("JOIN_TIMEOUT", "No join request received.", 4408), 20_000);
+ ws.on("message", (data, isBinary) => {
+ if (isBinary) this.onPcm(data);
+ else this.onJson(data);
+ });
+ ws.on("close", () => this.close());
+ ws.on("error", () => this.close());
+ }
+
+ send(message) {
+ if (this.ws.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(message));
+ }
+
+ fail(code, message, closeCode = 4400) {
+ this.send({ type: "error", code, message, fatal: true });
+ try { this.ws.close(closeCode, code.slice(0, 60)); } catch { /* already closed */ }
+ this.close();
+ }
+
+ error(code, message) {
+ this.send({ type: "error", code, message, fatal: false });
+ }
+
+ onJson(data) {
+ let msg;
+ try {
+ msg = JSON.parse(String(data));
+ } catch {
+ return this.fail("BAD_MESSAGE", "Malformed message.");
+ }
+ if (!msg || typeof msg !== "object") return this.fail("BAD_MESSAGE", "Malformed message.");
+
+ if (msg.type === "join") return void this.join(msg);
+ if (this.state !== "ready") return this.error("NOT_READY", "Not connected yet.");
+ switch (msg.type) {
+ case "move": return void this.move(msg);
+ case "mute": return void this.setMuted(Boolean(msg.muted));
+ case "deafen": return void this.setDeafened(Boolean(msg.deafened));
+ case "chat": return void this.sendChat(msg);
+ case "whisper": return void this.setWhisper(msg);
+ case "leave": return void this.close();
+ default: return this.error("UNKNOWN_MESSAGE", "Unknown message type.");
+ }
+ }
+
+ async join(msg) {
+ if (this.state !== "awaiting-join") return this.error("ALREADY_JOINED", "Already joined.");
+ clearTimeout(this.joinTimer);
+ const { config, limiter, ip, log } = this.ctx;
+
+ if (!config.server.password) return this.fail("VOICE_DISABLED", "Web voice is disabled: no server password is configured.", 4403);
+ if (!config.server.host) return this.fail("NOT_CONFIGURED", "The TeamSpeak host is not configured.", 4503);
+
+ const lockedMs = limiter.lockedFor(ip);
+ if (lockedMs > 0) {
+ return this.fail("LOCKED_OUT", `Too many wrong passwords. Try again in ${Math.ceil(lockedMs / 60_000)} min.`, 4429);
+ }
+ // The gateway itself verifies the server password before spending any
+ // resources on a TeamSpeak connection; the TeamSpeak server checks it again.
+ if (!passwordMatches(msg.password, config.server.password)) {
+ limiter.recordFailure(ip);
+ log.warn(`[voice] wrong password from ${ip}`);
+ return this.fail("BAD_PASSWORD", "Wrong server password.", 4401);
+ }
+ limiter.recordSuccess(ip);
+
+ const nickname = sanitizeNickname(msg.nickname, config.voice.nicknamePrefix);
+ if (!nickname) return this.fail("NICKNAME_INVALID", "Nickname must be 3-30 characters.");
+
+ let targetChannel = null;
+ if (msg.channelId !== undefined && msg.channelId !== null && msg.channelId !== "") {
+ if (!CHANNEL_ID.test(String(msg.channelId))) return this.fail("BAD_CHANNEL", "Invalid channel.");
+ targetChannel = String(msg.channelId);
+ }
+ this.serverDecode = msg.serverDecode === true;
+ this.state = "connecting";
+
+ let identity = null;
+ if (typeof msg.identity === "string" && msg.identity.length > 0 && msg.identity.length < 512) {
+ try { identity = parseIdentity(msg.identity); } catch { identity = null; }
+ }
+ try {
+ identity ??= await generateIdentity(config.voice.identitySecurityLevel, { signal: AbortSignal.timeout(CONNECT_TIMEOUT_MS) });
+ } catch {
+ return this.fail("IDENTITY_FAILED", "Could not create a TeamSpeak identity.", 4500);
+ }
+ if (this.closing) return;
+
+ const client = new Client(identity, `${config.server.host}:${config.server.voicePort}`, nickname, {
+ serverPassword: config.server.password,
+ defaultChannel: config.voice.defaultChannel || undefined,
+ logger: {
+ debug() {},
+ info() {},
+ warn: (m) => log.warn(`[ts ${nickname}] ${m}`),
+ error: (m) => log.error(`[ts ${nickname}] ${m}`),
+ },
+ });
+ this.client = client;
+ this.attachClientEvents(client);
+
+ try {
+ await client.connect({ signal: AbortSignal.timeout(CONNECT_TIMEOUT_MS) });
+ await client.view.whenReady({ signal: AbortSignal.timeout(CONNECT_TIMEOUT_MS) });
+ } catch (error) {
+ if (this.closing) return;
+ const { code, message } = describeError(error);
+ log.warn(`[voice] ${ip} could not connect as "${nickname}": ${code} ${error?.message ?? ""}`);
+ return this.fail(code, message, 4502);
+ }
+ if (this.closing) return;
+
+ try {
+ // Without this the server only reports clients of our own channel.
+ await subscribeAllChannels(client, { timeoutMs: 5000 });
+ } catch (error) {
+ log.warn(`[voice] could not subscribe to all channels: ${error?.message ?? error}`);
+ }
+
+ this.encoder = createOpusCodec({ bitrate: OPUS_BITRATE });
+ this.state = "ready";
+ log.info(`[voice] ${ip} joined as "${nickname}" (clid ${client.clientID})`);
+ this.send({ type: "joined", identity: identity.export(), nickname, ...snapshot(client) });
+
+ if (targetChannel && targetChannel !== String(client.channelID)) {
+ await this.move({ channelId: targetChannel, password: msg.channelPassword });
+ }
+ }
+
+ attachClientEvents(client) {
+ const on = (event, fn) => this.unsubscribers.push(client.on(event, fn));
+ on("viewChanged", () => this.scheduleState());
+ on("clientMoved", () => this.scheduleState());
+ on("clientLeave", (event) => {
+ const id = Number(event?.clientID);
+ const decoder = this.decoders.get(id);
+ if (decoder) { decoder.dispose(); this.decoders.delete(id); }
+ if (this.whisperTargetIds.delete(id)) {
+ if (!this.whisperTargetIds.size) this.whisperActive = false;
+ this.send({ type: "whisperTargets", targetIds: [...this.whisperTargetIds], active: this.whisperActive });
+ }
+ });
+ on("textMessage", (event) => {
+ const scope = event.targetMode === 1 ? "private" : event.targetMode === 2 ? "channel" : "server";
+ if (scope === "private" && Number(event.invokerID) === Number(client.clientID)) return;
+ const targetId = scope === "channel"
+ ? String(client.channelID)
+ : event.targetID ? String(event.targetID) : undefined;
+ this.send({
+ type: "chatMessage",
+ scope,
+ ...(targetId ? { targetId } : {}),
+ invokerId: Number(event.invokerID),
+ invokerName: event.invokerName,
+ senderUid: String(event.invokerUID ?? ""),
+ message: event.message,
+ timestamp: Date.now(),
+ });
+ });
+ on("kicked", (event) => { this.kickReason = event?.reasonMessage ?? ""; });
+ on("voiceData", (event) => this.onTeamSpeakVoice(event));
+ on("disconnected", (event) => {
+ if (this.closing || this.state !== "ready") return;
+ const reason = this.kickReason
+ ? `Kicked: ${this.kickReason}`
+ : event?.error ? describeError(event.error).message : "Disconnected from TeamSpeak.";
+ this.send({ type: "disconnected", reason });
+ try { this.ws.close(1000, "disconnected"); } catch { /* ignore */ }
+ this.close();
+ });
+ }
+
+ scheduleState() {
+ if (this.stateTimer || this.state !== "ready") return;
+ this.stateTimer = setTimeout(() => {
+ this.stateTimer = null;
+ if (this.state === "ready" && this.client) this.send({ type: "state", ...snapshot(this.client) });
+ }, STATE_THROTTLE_MS);
+ }
+
+ async move(msg) {
+ const id = String(msg.channelId ?? "");
+ if (!CHANNEL_ID.test(id)) return this.error("BAD_CHANNEL", "Invalid channel.");
+ const password = typeof msg.password === "string" && msg.password ? msg.password.slice(0, 512) : undefined;
+ try {
+ await moveClient(this.client, this.client.clientID, asChannelID(BigInt(id)), { password, timeoutMs: 5000 });
+ this.scheduleState();
+ } catch (error) {
+ const { code, message } = describeError(error);
+ this.send({ type: "error", code, message, fatal: false, channelId: id });
+ }
+ }
+
+ async sendChat(msg) {
+ const text = typeof msg.message === "string" ? msg.message.trim().slice(0, MAX_CHAT_LENGTH) : "";
+ if (!text) return;
+ const scope = msg.scope === "private" ? "private" : msg.scope === "server" ? "server" : "channel";
+ try {
+ if (scope === "private") {
+ const targetId = Number(msg.targetId);
+ if (!Number.isInteger(targetId) || targetId <= 0 || targetId > 65535) {
+ return this.error("BAD_TARGET", "Invalid private message recipient.");
+ }
+ await sendPrivateMessage(this.client, asClientID(targetId), text, { timeoutMs: 5000 });
+ const selfId = Number(this.client.clientID);
+ const selfEntry = this.client.view?.clients?.get?.(this.client.clientID);
+ this.send({
+ type: "chatMessage",
+ scope: "private",
+ targetId: String(targetId),
+ invokerId: selfId,
+ invokerName: selfEntry?.nickname || "you",
+ senderUid: "",
+ message: text,
+ timestamp: Date.now(),
+ });
+ } else if (scope === "server") {
+ await sendServerMessage(this.client, text, { timeoutMs: 5000 });
+ } else {
+ await sendChannelMessage(this.client, text, { timeoutMs: 5000 });
+ }
+ } catch (error) {
+ const { code, message } = describeError(error);
+ this.error(code, message);
+ }
+ }
+
+ setWhisper(msg) {
+ const selfId = Number(this.client?.clientID ?? 0);
+ if (Array.isArray(msg.targetIds)) {
+ const clean = [];
+ for (const raw of msg.targetIds) {
+ const id = Number(raw);
+ if (Number.isInteger(id) && id > 0 && id <= 65535 && id !== selfId && !clean.includes(id)) {
+ clean.push(id);
+ if (clean.length >= MAX_WHISPER_TARGETS) break;
+ }
+ }
+ this.whisperTargetIds = new Set(clean);
+ }
+ if (typeof msg.active === "boolean") {
+ this.whisperActive = msg.active && this.whisperTargetIds.size > 0;
+ } else if (!this.whisperTargetIds.size) {
+ this.whisperActive = false;
+ }
+ this.send({ type: "whisperTargets", targetIds: [...this.whisperTargetIds], active: this.whisperActive });
+ }
+
+ async setMuted(muted) {
+ this.muted = muted;
+ if (muted) this.endOfSpeech();
+ try { await updateClient(this.client, { isInputMuted: muted }, { timeoutMs: 5000 }); }
+ catch (error) { this.error(describeError(error).code, "Could not update mute state."); }
+ }
+
+ async setDeafened(deafened) {
+ this.deafened = deafened;
+ try { await updateClient(this.client, { isOutputMuted: deafened }, { timeoutMs: 5000 }); }
+ catch (error) { this.error(describeError(error).code, "Could not update deafen state."); }
+ }
+
+ // ---- audio ----------------------------------------------------------------
+
+ takeToken() {
+ const now = Date.now();
+ this.tokens = Math.min(FRAME_BURST, this.tokens + ((now - this.tokensAt) / 1000) * FRAME_RATE);
+ this.tokensAt = now;
+ if (this.tokens < 1) return false;
+ this.tokens -= 1;
+ return true;
+ }
+
+ sendVoiceOrWhisper(payload) {
+ if (this.whisperActive && this.whisperTargetIds.size > 0) {
+ this.client.sendWhisper(payload, { clients: [...this.whisperTargetIds].map(asClientID) }, 4 /* Codec.OpusVoice */);
+ } else {
+ this.client.sendVoice(payload, 4 /* Codec.OpusVoice */);
+ }
+ }
+
+ onPcm(frame) {
+ if (this.state !== "ready" || this.muted || !this.encoder) return;
+ if (!Buffer.isBuffer(frame) || frame.length !== FRAME_BYTES) {
+ if (++this.badFrames > 100) this.fail("BAD_AUDIO", "Malformed audio frames.");
+ return;
+ }
+ if (!this.takeToken()) return;
+ try {
+ const encoded = this.encoder.encode(frame);
+ this.sendVoiceOrWhisper(encoded);
+ } catch {
+ return;
+ }
+ clearTimeout(this.eosTimer);
+ this.eosTimer = setTimeout(() => this.endOfSpeech(), END_OF_SPEECH_MS);
+ }
+
+ /** An empty voice packet tells TeamSpeak clients the transmission ended. */
+ endOfSpeech() {
+ clearTimeout(this.eosTimer);
+ this.eosTimer = null;
+ if (this.state !== "ready") return;
+ try { this.sendVoiceOrWhisper(new Uint8Array(0)); } catch { /* not connected */ }
+ }
+
+ speakerDecoder(clientId) {
+ let decoder = this.decoders.get(clientId);
+ if (decoder) {
+ this.decoders.delete(clientId); // refresh LRU position
+ } else {
+ decoder = createOpusCodec();
+ while (this.decoders.size >= MAX_SPEAKER_DECODERS) {
+ const [oldestId, oldest] = this.decoders.entries().next().value;
+ oldest.dispose();
+ this.decoders.delete(oldestId);
+ }
+ }
+ this.decoders.set(clientId, decoder);
+ return decoder;
+ }
+
+ onTeamSpeakVoice(event) {
+ if (this.state !== "ready" || this.deafened || this.ws.readyState !== WebSocket.OPEN) return;
+ const clientId = Number(event.clientID);
+ if (clientId === Number(this.client.clientID) || clientId < 1 || clientId > 65535) return;
+ if (event.codec !== 4 && event.codec !== 5) return; // legacy Speex/CELT are not supported
+ const data = event.data instanceof Uint8Array ? event.data : new Uint8Array(0);
+
+ let kind = event.codec;
+ let payload = data;
+ if (this.serverDecode) {
+ if (this.ws.bufferedAmount > MAX_BUFFERED_PCM_BYTES) return;
+ kind = KIND_PCM;
+ if (data.length > 0) {
+ try { payload = this.speakerDecoder(clientId).decode(Buffer.from(data.buffer, data.byteOffset, data.byteLength)); }
+ catch { return; }
+ }
+ } else if (this.ws.bufferedAmount > MAX_BUFFERED_OPUS_BYTES) {
+ return;
+ }
+
+ const packet = Buffer.allocUnsafe(3 + payload.length);
+ packet[0] = kind;
+ packet.writeUInt16BE(clientId, 1);
+ packet.set(payload, 3);
+ this.ws.send(packet);
+ }
+
+ // ---- teardown -------------------------------------------------------------
+
+ close() {
+ if (this.closing) return;
+ this.closing = true;
+ const wasReady = this.state === "ready";
+ this.state = "closed";
+ clearTimeout(this.joinTimer);
+ clearTimeout(this.stateTimer);
+ clearTimeout(this.eosTimer);
+ for (const off of this.unsubscribers) { try { off(); } catch { /* ignore */ } }
+ this.unsubscribers = [];
+ try { this.encoder?.dispose(); } catch { /* ignore */ }
+ this.encoder = null;
+ for (const decoder of this.decoders.values()) { try { decoder.dispose(); } catch { /* ignore */ } }
+ this.decoders.clear();
+ this.whisperTargetIds.clear();
+ this.whisperActive = false;
+ const client = this.client;
+ this.client = null;
+ if (client) {
+ client.disconnect({ reasonMessage: "Left from the web", signal: AbortSignal.timeout(2000) })
+ .catch(() => undefined);
+ }
+ if (wasReady) this.ctx.log.info(`[voice] ${this.ctx.ip} left`);
+ if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
+ try { this.ws.close(1000, "bye"); } catch { /* ignore */ }
+ }
+ this.ctx.onClose();
+ }
+}
diff --git a/index.css b/index.css
index 9d058f2..7475a64 100644
--- a/index.css
+++ b/index.css
@@ -374,6 +374,757 @@ body {
transition: none;
}
+/* ---------------------------------------------------------------------------
+ Web voice
+ --------------------------------------------------------------------------- */
+
+body.voice-live {
+ padding-bottom: 6.5rem;
+}
+
+/* Top login bar (left = status, right = [ login ]) */
+
+.voice-topbar {
+ border-bottom: 2px solid var(--cl-gray-2);
+ background-color: var(--cl-gray-1);
+}
+
+.voice-topbar-bar {
+ padding: 0.65rem 1.25rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.voice-topbar-identity {
+ color: var(--cl-gray-7);
+ font-size: 0.85rem;
+ font-weight: bold;
+ text-transform: uppercase;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.voice-topbar-identity[data-ready="1"] {
+ color: var(--cl-green-6);
+}
+
+.voice-login-btn {
+ padding: 4px 10px;
+ color: var(--cl-orange-6);
+ font-family: inherit;
+ font-size: 0.85rem;
+ font-weight: bold;
+ text-transform: uppercase;
+ border: 2px solid var(--cl-orange-6);
+ background-color: var(--cl-gray-0);
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+.voice-login-btn::before {
+ content: "[ ";
+}
+
+.voice-login-btn::after {
+ content: " ]";
+}
+
+.voice-login-btn:hover {
+ color: var(--cl-black);
+ background-color: var(--cl-magenta-7);
+ border-color: transparent;
+}
+
+.voice-topbar-hint {
+ margin: 0;
+ padding: 0.5rem 1.25rem 0.75rem;
+ color: var(--cl-gray-7);
+ font-size: 0.85rem;
+ border-top: 2px solid var(--cl-gray-2);
+ background-color: var(--cl-gray-0);
+}
+
+.voice-topbar-hint[data-kind="error"] {
+ color: var(--cl-red-6);
+}
+
+/* Join buttons on every channel card */
+
+.channel-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ flex-shrink: 0;
+}
+
+.channel-join,
+.channel-here {
+ padding: 4px 10px;
+ font-family: inherit;
+ font-size: 0.85rem;
+ font-weight: bold;
+ text-transform: uppercase;
+ border: 2px solid;
+ background-color: transparent;
+}
+
+.channel-join {
+ color: var(--cl-orange-6);
+ border-color: var(--cl-orange-6);
+ background-color: var(--cl-gray-0);
+ cursor: pointer;
+}
+
+.channel-join::before,
+.channel-here::before {
+ content: "[ ";
+}
+
+.channel-join::after,
+.channel-here::after {
+ content: " ]";
+}
+
+.channel-join:hover,
+.channel-join:focus-visible {
+ color: var(--cl-black);
+ background-color: var(--cl-magenta-7);
+ border-color: transparent;
+ outline: none;
+}
+
+.voice-connecting .channel-join {
+ opacity: 0.5;
+ pointer-events: none;
+}
+
+.channel-here {
+ color: var(--cl-green-6);
+ border-color: var(--cl-green-6);
+ background-color: var(--cl-gray-0);
+}
+
+.channel-lock {
+ margin-left: 0.75rem;
+ padding: 0 4px;
+ color: var(--cl-yellow);
+ font-size: 0.75rem;
+ font-weight: bold;
+ text-transform: uppercase;
+ border: 2px solid var(--cl-yellow);
+}
+
+.channel.is-current {
+ border-color: var(--cl-green-6);
+}
+
+.channel.is-current .channel-header {
+ border-bottom-color: var(--cl-green-6);
+}
+
+/* Live clients, per-user volume controls & direct message buttons */
+
+.voice-live #refresh-bar {
+ display: none;
+}
+
+.client.is-self .client-name {
+ color: var(--cl-white);
+ font-weight: bold;
+}
+
+.client-you {
+ color: var(--cl-gray-6);
+ font-size: 0.85rem;
+ margin-left: 0.5rem;
+}
+
+.client.is-talking::before {
+ content: ">";
+ color: var(--cl-green-6);
+}
+
+.client.is-talking .client-name {
+ color: var(--cl-green-6);
+}
+
+.client-muted {
+ color: var(--cl-gray-6);
+ font-size: 0.85rem;
+ margin-left: 0.5rem;
+}
+
+.client-controls {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 0.65rem;
+ flex-shrink: 0;
+}
+
+.client-volume {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ color: var(--cl-gray-6);
+ font-size: 0.75rem;
+ font-weight: bold;
+}
+
+.client-volume-slider {
+ width: 80px;
+ margin: 0;
+ accent-color: var(--cl-orange-6);
+ cursor: pointer;
+}
+
+.client-volume-value {
+ min-width: 3.1rem;
+ text-align: right;
+ color: var(--cl-gray-7);
+ font-variant-numeric: tabular-nums;
+}
+
+.client-msg-btn {
+ padding: 2px 6px;
+ color: var(--cl-gray-6);
+ font-family: inherit;
+ font-size: 0.75rem;
+ font-weight: bold;
+ text-transform: uppercase;
+ border: 2px solid var(--cl-gray-3);
+ background-color: var(--cl-gray-0);
+ cursor: pointer;
+}
+
+.client-msg-btn::before {
+ content: "[ ";
+}
+
+.client-msg-btn::after {
+ content: " ]";
+}
+
+.client-msg-btn:hover {
+ color: var(--cl-black);
+ background-color: var(--cl-magenta-7);
+ border-color: transparent;
+}
+
+.client-msg-btn.is-active {
+ color: var(--cl-cyan-6);
+ border-color: var(--cl-cyan-6);
+}
+
+.client-msg-btn.has-unread {
+ color: var(--cl-orange-6);
+ border-color: var(--cl-orange-6);
+}
+
+/* Inline channel & private message chat underneath the joined channel */
+
+.channel-chat {
+ border-top: 2px solid var(--cl-gray-2);
+ background-color: var(--cl-gray-0);
+}
+
+.channel-chat-header {
+ padding: 0.45rem 0.75rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ border-bottom: 2px solid var(--cl-gray-2);
+ background-color: var(--cl-gray-1);
+}
+
+.channel-chat-tabs {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.45rem;
+}
+
+.channel-chat-tab {
+ padding: 2px 8px;
+ color: var(--cl-gray-6);
+ font-family: inherit;
+ font-size: 0.78rem;
+ font-weight: bold;
+ text-transform: uppercase;
+ border: 2px solid var(--cl-gray-3);
+ background-color: var(--cl-gray-0);
+ cursor: pointer;
+}
+
+.channel-chat-tab::before {
+ content: "[ ";
+}
+
+.channel-chat-tab::after {
+ content: " ]";
+}
+
+.channel-chat-tab:hover {
+ color: var(--cl-black);
+ background-color: var(--cl-magenta-7);
+ border-color: transparent;
+}
+
+.channel-chat-tab.is-active {
+ color: var(--cl-cyan-6);
+ border-color: var(--cl-cyan-6);
+}
+
+.channel-chat-tab.is-dm.is-active {
+ color: var(--cl-magenta-7);
+ border-color: var(--cl-magenta-7);
+}
+
+.channel-chat-tab.has-unread {
+ color: var(--cl-orange-6);
+ border-color: var(--cl-orange-6);
+}
+
+.channel-chat-messages {
+ max-height: 190px;
+ overflow-y: auto;
+ padding: 0.65rem 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.channel-chat-empty {
+ color: var(--cl-gray-5);
+ font-size: 0.85rem;
+}
+
+.channel-chat-msg {
+ display: flex;
+ align-items: baseline;
+ flex-wrap: wrap;
+ gap: 0.45rem;
+ font-size: 0.88rem;
+ line-height: 1.4;
+ word-break: break-word;
+}
+
+.channel-chat-time {
+ color: var(--cl-gray-5);
+ font-size: 0.75rem;
+ flex-shrink: 0;
+}
+
+.channel-chat-author {
+ color: var(--cl-orange-6);
+ font-weight: bold;
+ flex-shrink: 0;
+}
+
+.channel-chat-msg.is-self .channel-chat-author {
+ color: var(--cl-green-6);
+}
+
+.channel-chat-msg.is-private .channel-chat-author {
+ color: var(--cl-magenta-7);
+}
+
+.channel-chat-msg.is-server .channel-chat-author {
+ color: var(--cl-cyan-6);
+}
+
+.channel-chat-text {
+ color: var(--cl-white);
+}
+
+.channel-chat-form {
+ padding: 0.55rem 0.75rem;
+ display: flex;
+ align-items: stretch;
+ gap: 0.5rem;
+ border-top: 2px solid var(--cl-gray-2);
+ background-color: var(--cl-gray-1);
+}
+
+.channel-chat-input {
+ flex: 1 1 auto;
+ min-width: 0;
+ padding: 0.45rem 0.65rem;
+ color: var(--cl-white);
+ font-family: inherit;
+ font-size: 0.9rem;
+ border: 2px solid var(--cl-gray-3);
+ background-color: var(--cl-gray-0);
+ outline: none;
+}
+
+.channel-chat-input:focus {
+ border-color: var(--cl-orange-6);
+}
+
+.channel-chat-send {
+ padding: 0.45rem 0.85rem;
+ color: var(--cl-orange-6);
+ font-family: inherit;
+ font-size: 0.85rem;
+ font-weight: bold;
+ text-transform: uppercase;
+ border: 2px solid var(--cl-orange-6);
+ background-color: var(--cl-gray-0);
+ cursor: pointer;
+}
+
+.channel-chat-send::before {
+ content: "[ ";
+}
+
+.channel-chat-send::after {
+ content: " ]";
+}
+
+.channel-chat-send:hover {
+ color: var(--cl-black);
+ background-color: var(--cl-magenta-7);
+ border-color: transparent;
+}
+
+/* Fixed voice controls bar at the bottom of the screen while connected */
+
+.voice-bar {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 50;
+ border-top: 2px solid var(--cl-green-6);
+ background-color: var(--cl-gray-0);
+ box-shadow: 0 -6px 24px rgba(0, 0, 0, 0.65);
+}
+
+.voice-bar-main {
+ max-width: 760px;
+ margin: 0 auto;
+ padding: 0.75rem 1.5rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 1rem;
+}
+
+.voice-bar-info {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ min-width: 0;
+}
+
+.voice-live-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 4px 10px;
+ color: var(--cl-green-6);
+ font-size: 0.8rem;
+ font-weight: bold;
+ text-transform: uppercase;
+ border: 2px solid var(--cl-green-6);
+}
+
+.voice-live-badge .pulse-dot {
+ width: 8px;
+ height: 8px;
+ margin-right: 0.6rem;
+}
+
+.voice-where {
+ display: flex;
+ align-items: baseline;
+ gap: 0.6rem;
+ min-width: 0;
+ font-weight: bold;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.voice-where #voice-channel::before {
+ content: "> ";
+ color: var(--cl-orange-6);
+}
+
+.voice-as {
+ color: var(--cl-gray-6);
+ font-weight: normal;
+}
+
+.voice-buttons {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.6rem;
+}
+
+.btn-voice {
+ padding: 6px 12px;
+ color: var(--cl-gray-8);
+ font-family: inherit;
+ font-size: 0.85rem;
+ font-weight: bold;
+ text-transform: uppercase;
+ border: 2px solid var(--cl-gray-4);
+ background-color: transparent;
+ cursor: pointer;
+ user-select: none;
+ touch-action: none;
+}
+
+.btn-voice::before {
+ content: "[ ";
+}
+
+.btn-voice::after {
+ content: " ]";
+}
+
+.btn-voice:hover {
+ color: var(--cl-black);
+ background-color: var(--cl-magenta-7);
+ border-color: transparent;
+}
+
+.btn-voice[aria-pressed="true"] {
+ color: var(--cl-red-6);
+ border-color: var(--cl-red-6);
+}
+
+.btn-voice[aria-expanded="true"] {
+ color: var(--cl-orange-6);
+ border-color: var(--cl-orange-6);
+}
+
+.btn-voice.is-active {
+ color: var(--cl-black);
+ background-color: var(--cl-green-6);
+ border-color: var(--cl-green-6);
+}
+
+.btn-voice-talk {
+ color: var(--cl-green-6);
+ border-color: var(--cl-green-6);
+}
+
+.btn-voice-danger {
+ color: var(--cl-red-6);
+ border-color: var(--cl-red-6);
+}
+
+.voice-meter {
+ position: relative;
+ height: 4px;
+ background-color: var(--cl-gray-2);
+}
+
+.voice-meter-fill {
+ width: 0%;
+ height: 100%;
+ background-color: var(--cl-gray-5);
+}
+
+.voice-meter-fill.is-open {
+ background-color: var(--cl-green-6);
+}
+
+.voice-meter-threshold {
+ position: absolute;
+ top: -3px;
+ bottom: -3px;
+ width: 2px;
+ background-color: var(--cl-orange-6);
+}
+
+.voice-settings {
+ max-width: 760px;
+ margin: 0 auto;
+ padding: 1rem 1.5rem;
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr);
+ align-items: end;
+ gap: 1.5rem;
+ border-bottom: 2px solid var(--cl-gray-2);
+ background-color: var(--cl-gray-1);
+}
+
+.voice-setting {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+}
+
+.voice-setting input[type="range"] {
+ width: 100%;
+ margin: 0.35rem 0;
+ accent-color: var(--cl-orange-6);
+}
+
+.voice-bar-message {
+ max-width: 760px;
+ margin: 0 auto;
+ padding: 0.5rem 1.5rem;
+ color: var(--cl-gray-7);
+ font-size: 0.85rem;
+ border-bottom: 2px solid var(--cl-gray-2);
+}
+
+.voice-bar-message[data-kind="error"] {
+ color: var(--cl-red-6);
+}
+
+/* Notice shown above the channel list after a call ends */
+
+.voice-notice {
+ margin: 0 0 1.5rem;
+ padding: 0.75rem 1rem;
+ color: var(--cl-gray-8);
+ font-size: 0.9rem;
+ border: 2px solid var(--cl-gray-4);
+ background-color: var(--cl-gray-1);
+}
+
+.voice-notice::before {
+ content: "! ";
+ color: var(--cl-orange-6);
+ font-weight: bold;
+}
+
+.voice-notice[data-kind="error"] {
+ color: var(--cl-red-6);
+ border-color: var(--cl-red-6);
+}
+
+/* Login modal ([ login ] button dialog) */
+
+.voice-dialog {
+ width: min(460px, calc(100vw - 2rem));
+ padding: 0;
+ color: var(--cl-white);
+ font-family: inherit;
+ border: 2px solid var(--cl-orange-6);
+ background-color: var(--cl-gray-0);
+}
+
+.voice-dialog::backdrop {
+ background-color: hsla(140, 1%, 6%, 0.82);
+}
+
+.voice-dialog-header {
+ padding: 0.85rem 1.25rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ border-bottom: 2px solid var(--cl-gray-2);
+ background-color: var(--cl-gray-2);
+}
+
+.voice-dialog-header h2 {
+ margin: 0;
+ font-size: 0.95rem;
+ text-transform: uppercase;
+}
+
+.voice-dialog-header h2::before {
+ content: "> ";
+ color: var(--cl-orange-6);
+}
+
+.voice-dialog-target {
+ padding: 3px 8px;
+ color: var(--cl-cyan-6);
+ font-size: 0.8rem;
+ font-weight: bold;
+ text-transform: uppercase;
+ border: 2px solid var(--cl-cyan-6);
+ max-width: 55%;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.voice-dialog-body {
+ padding: 1.25rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.voice-field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+}
+
+.voice-field-tag {
+ color: var(--cl-orange-6);
+ font-size: 0.85rem;
+ font-weight: bold;
+ text-transform: uppercase;
+}
+
+.voice-field input {
+ width: 100%;
+ padding: 0.65rem 0.8rem;
+ color: var(--cl-white);
+ font-family: inherit;
+ font-size: 0.95rem;
+ border: 2px solid var(--cl-gray-3);
+ background-color: var(--cl-gray-1);
+ outline: none;
+}
+
+.voice-field input:focus {
+ border-color: var(--cl-orange-6);
+}
+
+.voice-dialog-message {
+ margin: 0;
+ color: var(--cl-gray-7);
+ font-size: 0.85rem;
+}
+
+.voice-dialog-message[data-kind="error"] {
+ color: var(--cl-red-6);
+}
+
+.voice-dialog-actions {
+ padding: 0.85rem 1.25rem 1.25rem;
+ display: flex;
+ justify-content: flex-end;
+ align-items: stretch;
+ gap: 0.75rem;
+}
+
+.voice-dialog-actions .voice-submit {
+ width: auto;
+ min-width: 8rem;
+ margin: 0;
+ font-family: inherit;
+ cursor: pointer;
+}
+
+.voice-dialog-actions .voice-submit:disabled {
+ color: var(--cl-gray-5);
+ border-color: var(--cl-gray-4);
+ background-color: transparent;
+ cursor: wait;
+}
+
+[hidden] {
+ display: none !important;
+}
+
@media (max-width: 640px) {
body {
margin: 2vh 4vw;
@@ -392,4 +1143,30 @@ body {
flex-direction: column;
gap: 0.75rem;
}
+
+ .channel-actions {
+ width: 100%;
+ justify-content: space-between;
+ }
+
+ .client {
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ }
+
+ .voice-settings {
+ grid-template-columns: 1fr;
+ gap: 1rem;
+ }
+
+ .voice-bar-main,
+ .voice-settings,
+ .voice-bar-message {
+ padding-left: 1rem;
+ padding-right: 1rem;
+ }
+
+ .voice-buttons {
+ width: 100%;
+ }
}
diff --git a/pyproject.toml b/pyproject.toml
index ffeca58..0f44c61 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,5 +7,6 @@ requires-python = ">=3.13"
dependencies = [
"flask>=3.1.3",
"flask-cors>=6.0.5",
+ "python-dotenv>=1.0.1",
"requests>=2.34.2",
]
diff --git a/requirements.txt b/requirements.txt
index 0062c2f..123d5e7 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,4 @@
Flask>=3.0,<4
-flask-cors>=4.0,<5
+flask-cors>=4.0,<7
+python-dotenv>=1.0,<2
requests>=2.31,<3
diff --git a/run.py b/run.py
new file mode 100644
index 0000000..0ba7220
--- /dev/null
+++ b/run.py
@@ -0,0 +1,121 @@
+"""Start the dashboard and the browser voice gateway with one command.
+
+ uv run run.py
+
+With web voice enabled (config.toml [voice] enabled = true) the Node gateway
+listens on the public port (web.port), handles /ws/voice and forwards every
+other request to Flask on 127.0.0.1:web.dashboard_port. With voice disabled
+(or Node missing) only the dashboard runs, directly on the public port.
+"""
+
+import os
+import shutil
+import signal
+import subprocess
+import sys
+import time
+
+from config import BASE_DIR, CONFIG
+
+GATEWAY_DIR = BASE_DIR / "gateway"
+MIN_NODE = (22, 12)
+
+
+def log(message):
+ print(f"[run] {message}", flush=True)
+
+
+def node_ok(node):
+ try:
+ out = subprocess.run([node, "--version"], capture_output=True, text=True, check=True).stdout
+ major, minor = (int(part) for part in out.strip().lstrip("v").split(".")[:2])
+ return (major, minor) >= MIN_NODE, out.strip()
+ except (OSError, ValueError, subprocess.CalledProcessError):
+ return False, "unknown"
+
+
+def ensure_gateway_deps():
+ if (GATEWAY_DIR / "node_modules" / "@honeybbq" / "teamspeak").is_dir():
+ return True
+ npm = shutil.which("npm")
+ if not npm:
+ log("npm not found; cannot install the voice gateway dependencies")
+ return False
+ lock = (GATEWAY_DIR / "package-lock.json").exists()
+ cmd = [npm, "ci" if lock else "install", "--omit=dev", "--no-audit", "--no-fund"]
+ log(f"installing voice gateway dependencies: {' '.join(cmd[1:])}")
+ return subprocess.run(cmd, cwd=GATEWAY_DIR).returncode == 0
+
+
+def gateway_command():
+ """The Node command for the gateway, or None when voice cannot run."""
+ if not CONFIG["voice"].get("enabled"):
+ log("web voice disabled in config.toml; starting the dashboard only")
+ return None
+ node = shutil.which("node")
+ if not node:
+ log("Node.js not found; starting the dashboard only (install Node >= 22.12 for web voice)")
+ return None
+ ok, version = node_ok(node)
+ if not ok:
+ log(f"Node.js {version} is too old (need >= {'.'.join(map(str, MIN_NODE))}); starting the dashboard only")
+ return None
+ if not ensure_gateway_deps():
+ log("voice gateway dependencies missing; starting the dashboard only")
+ return None
+ return [node, str(GATEWAY_DIR / "src" / "index.js")]
+
+
+def main():
+ web = CONFIG["web"]
+ gateway_cmd = gateway_command()
+ env = dict(os.environ)
+
+ if gateway_cmd:
+ env.update(DASHBOARD_HOST="127.0.0.1", DASHBOARD_LISTEN_PORT=str(web["dashboard_port"]))
+ else:
+ env.update(DASHBOARD_HOST=str(web["host"]), DASHBOARD_LISTEN_PORT=str(web["port"]))
+
+ procs = [("dashboard", subprocess.Popen([sys.executable, str(BASE_DIR / "app.py")], cwd=BASE_DIR, env=env))]
+ if gateway_cmd:
+ procs.append(("gateway", subprocess.Popen(gateway_cmd, cwd=BASE_DIR, env=env)))
+ log(f"open http://{'localhost' if web['host'] in ('0.0.0.0', '::') else web['host']}:{web['port']}")
+
+ stopping = False
+
+ def stop(*_):
+ nonlocal stopping
+ if stopping:
+ return
+ stopping = True
+ for _, proc in procs:
+ if proc.poll() is None:
+ proc.send_signal(signal.SIGTERM)
+
+ signal.signal(signal.SIGINT, stop)
+ signal.signal(signal.SIGTERM, stop)
+
+ exit_code = 0
+ try:
+ while not stopping:
+ for name, proc in procs:
+ code = proc.poll()
+ if code is not None:
+ log(f"{name} exited with code {code}; shutting everything down")
+ exit_code = code or 1
+ stop()
+ break
+ time.sleep(0.5)
+ finally:
+ stop()
+ deadline = time.time() + 5
+ for _, proc in procs:
+ try:
+ proc.wait(timeout=max(0.1, deadline - time.time()))
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ sys.exit(exit_code)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/static/mic-worklet.js b/static/mic-worklet.js
new file mode 100644
index 0000000..814bf6a
--- /dev/null
+++ b/static/mic-worklet.js
@@ -0,0 +1,60 @@
+// Microphone capture worklet: resamples the context rate to 48 kHz mono and
+// posts 20 ms frames (960 Float32 samples) together with their RMS level.
+const OUT_RATE = 48000;
+const FRAME_SAMPLES = 960;
+
+class Ts6MicCapture extends AudioWorkletProcessor {
+ constructor() {
+ super();
+ this.ratio = sampleRate / OUT_RATE; // input samples per output sample
+ this.t = 0; // fractional read position; -1 refers to `last`
+ this.last = 0;
+ this.frame = new Float32Array(FRAME_SAMPLES);
+ this.offset = 0;
+ }
+
+ push(sample) {
+ this.frame[this.offset++] = sample;
+ if (this.offset < FRAME_SAMPLES) return;
+ let sum = 0;
+ for (let i = 0; i < FRAME_SAMPLES; i++) sum += this.frame[i] * this.frame[i];
+ const frame = this.frame;
+ this.frame = new Float32Array(FRAME_SAMPLES);
+ this.offset = 0;
+ this.port.postMessage({ samples: frame, rms: Math.sqrt(sum / FRAME_SAMPLES) }, [frame.buffer]);
+ }
+
+ process(inputs, outputs) {
+ const output = outputs[0] && outputs[0][0];
+ if (output) output.fill(0);
+ const channels = inputs[0];
+ if (!channels || channels.length === 0) return true;
+
+ // Downmix to mono.
+ let input = channels[0];
+ if (channels.length > 1) {
+ input = new Float32Array(input.length);
+ for (const ch of channels) for (let i = 0; i < input.length; i++) input[i] += ch[i] / channels.length;
+ }
+
+ if (this.ratio === 1) {
+ for (let i = 0; i < input.length; i++) this.push(input[i]);
+ return true;
+ }
+ // Linear interpolation resampler.
+ const len = input.length;
+ while (this.t < len - 1) {
+ const i0 = Math.floor(this.t);
+ const frac = this.t - i0;
+ const a = i0 < 0 ? this.last : input[i0];
+ const b = input[i0 + 1];
+ this.push(a + (b - a) * frac);
+ this.t += this.ratio;
+ }
+ this.t -= len;
+ this.last = input[len - 1];
+ return true;
+ }
+}
+
+registerProcessor("ts6-mic-capture", Ts6MicCapture);
diff --git a/static/voice.js b/static/voice.js
new file mode 100644
index 0000000..9cfa74b
--- /dev/null
+++ b/static/voice.js
@@ -0,0 +1,1145 @@
+// Browser voice client for the TeamSpeak dashboard.
+// Talks to the Node gateway over /ws/voice (see gateway/src/voice-session.js
+// for the wire protocol). No WebRTC: mic PCM goes up, Opus frames come down.
+//
+// UI layout:
+// - Top bar: shows current identity on the left and a [ browser connect ] button
+// on the right that opens a modal for [ nickname ] and [ server password ]
+// (both saved in localStorage).
+// - Each channel card has a [ join ] button; while connected, non-self users
+// show a per-user volume slider and a [ msg ] button for direct messages,
+// and the joined channel expands an inline chat box (with tabs for #channel
+// and @user direct messages) directly underneath its user list.
+// - Fixed bottom bar: mute / deafen / settings / leave controls.
+(() => {
+ "use strict";
+
+ const cfgEl = document.getElementById("voice-config");
+ const topbar = document.getElementById("voice-topbar");
+ const dialog = document.getElementById("voice-dialog");
+ if (!cfgEl || !topbar || !dialog) return;
+ const cfg = JSON.parse(cfgEl.textContent || "{}");
+ if (!cfg.enabled) return;
+
+ const $ = (id) => document.getElementById(id);
+ const ui = {
+ topbar,
+ topbarIdentity: $("voice-topbar-identity"),
+ loginBtn: $("voice-login-btn"),
+ dialog,
+ dialogTarget: $("voice-dialog-target"),
+ dialogMessage: $("voice-dialog-message"),
+ form: $("voice-join-form"),
+ nickname: $("voice-nickname"),
+ password: $("voice-password"),
+ submit: $("voice-submit"),
+ cancel: $("voice-cancel"),
+ message: $("voice-message"),
+ notice: $("voice-notice"),
+ bar: $("voice-bar"),
+ barMessage: $("voice-bar-message"),
+ channel: $("voice-channel"),
+ self: $("voice-self"),
+ meter: $("voice-meter"),
+ threshold: $("voice-threshold"),
+ mute: $("voice-mute"),
+ deafen: $("voice-deafen"),
+ settingsToggle: $("voice-settings-toggle"),
+ settings: $("voice-settings"),
+ mode: $("voice-mode"),
+ ptt: $("voice-ptt"),
+ leave: $("voice-leave"),
+ sensitivity: $("voice-sensitivity"),
+ volume: $("voice-volume"),
+ container: $("ajax-container"),
+ };
+
+ const STORE = {
+ nickname: "ts6voice.nickname",
+ password: "ts6voice.password",
+ identity: "ts6voice.identity",
+ sensitivity: "ts6voice.sensitivity",
+ volume: "ts6voice.volume",
+ mode: "ts6voice.mode",
+ userVolumes: "ts6voice.userVolumes",
+ };
+ const store = {
+ get: (k) => { try { return localStorage.getItem(k); } catch { return null; } },
+ set: (k, v) => { try { localStorage.setItem(k, v); } catch { /* private mode */ } },
+ };
+
+ const KIND_PCM = 0xf0;
+ const PTT_KEY = "KeyV";
+ const VAD_HANG_MS = 350;
+ const TALK_TIMEOUT_MS = 300;
+ const START_LATENCY_S = 0.06;
+ const MAX_AHEAD_S = 0.25;
+ const MAX_SEND_BUFFER = 16 * 1024;
+ const MAX_CHAT_HISTORY = 100;
+
+ let session = null; // active session, see startJoin()
+ let pendingJoin = null; // undefined = save only; null = join default channel; {cid,name} = join specific channel
+
+ // ---- per-user volume storage -----------------------------------------------
+
+ let savedUserVolumes = {};
+ try {
+ const raw = store.get(STORE.userVolumes);
+ if (raw) {
+ const parsed = JSON.parse(raw);
+ if (parsed && typeof parsed === "object") savedUserVolumes = parsed;
+ }
+ } catch {
+ savedUserVolumes = {};
+ }
+
+ function clampUserVolume(val) {
+ const num = Number(val);
+ if (!Number.isFinite(num)) return 100;
+ return Math.max(0, Math.min(200, Math.round(num)));
+ }
+
+ function getUserVolumePercent(clientId, nickname) {
+ if (session && session.userVolumes.has(clientId)) {
+ return session.userVolumes.get(clientId);
+ }
+ if (nickname && Object.prototype.hasOwnProperty.call(savedUserVolumes, nickname)) {
+ return clampUserVolume(savedUserVolumes[nickname]);
+ }
+ return 100;
+ }
+
+ function setUserVolumePercent(clientId, nickname, percent) {
+ const clamped = clampUserVolume(percent);
+ if (session) {
+ session.userVolumes.set(clientId, clamped);
+ if (session.playback) session.playback.setUserVolume(clientId, clamped / 100);
+ }
+ if (nickname) {
+ savedUserVolumes[nickname] = clamped;
+ store.set(STORE.userVolumes, JSON.stringify(savedUserVolumes));
+ }
+ return clamped;
+ }
+
+ // ---- messages & topbar summary ---------------------------------------------
+
+ function setText(node, text, kind = "info") {
+ if (!node) return;
+ node.textContent = text || "";
+ node.dataset.kind = kind;
+ node.hidden = !text;
+ }
+
+ /** Route a message to wherever the user is looking right now. */
+ function say(text, kind = "info") {
+ if (session && session.ready) setText(ui.barMessage, text, kind);
+ else if (ui.dialog.open) setText(ui.dialogMessage, text, kind);
+ else setText(ui.message, text, kind);
+ }
+
+ function updateTopbarSummary() {
+ const name = (ui.nickname.value || "").trim();
+ const pass = ui.password.value || "";
+ if (session && session.ready) {
+ ui.topbarIdentity.textContent = `[ ${session.nickname} // connected ]`;
+ ui.topbarIdentity.dataset.ready = "1";
+ } else if (name.length >= 3 && pass) {
+ ui.topbarIdentity.textContent = `[ ${name} // password saved ]`;
+ ui.topbarIdentity.dataset.ready = "1";
+ } else if (name.length >= 3) {
+ ui.topbarIdentity.textContent = `[ ${name} // no password ]`;
+ ui.topbarIdentity.dataset.ready = "";
+ } else {
+ ui.topbarIdentity.textContent = "[ not logged in ]";
+ ui.topbarIdentity.dataset.ready = "";
+ }
+ }
+
+ // ---- browser connect modal --------------------------------------------------
+
+ function openLoginModal(joinIntent = null, errorMsg = "") {
+ pendingJoin = joinIntent;
+ if (joinIntent === undefined) {
+ ui.dialogTarget.textContent = "credentials";
+ ui.submit.textContent = "save";
+ } else if (joinIntent && joinIntent.name) {
+ ui.dialogTarget.textContent = joinIntent.name;
+ ui.submit.textContent = "connect";
+ } else {
+ ui.dialogTarget.textContent = "default channel";
+ ui.submit.textContent = "connect";
+ }
+ setText(ui.dialogMessage, errorMsg, errorMsg ? "error" : "info");
+ if (!ui.dialog.open) ui.dialog.showModal();
+ (ui.nickname.value.trim().length >= 3 ? ui.password : ui.nickname).focus();
+ }
+
+ function closeLoginModal() {
+ pendingJoin = null;
+ if (ui.dialog.open) ui.dialog.close();
+ }
+
+ ui.loginBtn.addEventListener("click", () => {
+ if (session && session.ready) openLoginModal(undefined);
+ else openLoginModal(null);
+ });
+ ui.cancel.addEventListener("click", () => {
+ if (session && !session.ready) teardown("", "info");
+ closeLoginModal();
+ });
+ ui.dialog.addEventListener("cancel", (e) => {
+ if (session && !session.ready) {
+ e.preventDefault();
+ teardown("", "info");
+ closeLoginModal();
+ }
+ });
+ ui.dialog.addEventListener("click", (e) => {
+ if (e.target === ui.dialog && (!session || session.ready)) closeLoginModal();
+ });
+
+ // ---- preferences --------------------------------------------------------------
+
+ ui.nickname.value = store.get(STORE.nickname) || "";
+ ui.password.value = store.get(STORE.password) || "";
+ if (store.get(STORE.sensitivity) !== null) ui.sensitivity.value = store.get(STORE.sensitivity);
+ if (store.get(STORE.volume) !== null) ui.volume.value = store.get(STORE.volume);
+ let mode = store.get(STORE.mode) === "ptt" ? "ptt" : "vad";
+ updateTopbarSummary();
+
+ ui.nickname.addEventListener("input", () => {
+ store.set(STORE.nickname, ui.nickname.value.trim());
+ updateTopbarSummary();
+ setText(ui.dialogMessage, "");
+ });
+ ui.password.addEventListener("input", () => {
+ store.set(STORE.password, ui.password.value);
+ updateTopbarSummary();
+ setText(ui.dialogMessage, "");
+ });
+
+ function levelFromRms(rms) {
+ const db = 20 * Math.log10(Math.max(rms, 1e-6));
+ return Math.min(1, Math.max(0, (db + 60) / 60));
+ }
+ const threshold = () => 1 - Number(ui.sensitivity.value) / 100;
+ function updateThresholdMark() { ui.threshold.style.left = `${threshold() * 100}%`; }
+ function applyModeUi() {
+ ui.mode.textContent = mode === "ptt" ? "push to talk" : "voice activity";
+ ui.ptt.hidden = mode !== "ptt";
+ ui.threshold.hidden = mode === "ptt";
+ }
+ updateThresholdMark();
+ applyModeUi();
+
+ // ---- live channel list & chat (channel + individual DMs) --------------------
+
+ function sortChannels(channels, pid = "0", depth = 0) {
+ const byOrder = new Map();
+ for (const c of channels) if (c.parentId === pid) byOrder.set(c.order, c);
+ const ordered = [];
+ let current = "0";
+ let guard = 0;
+ while (byOrder.has(current) && guard++ <= channels.length && depth < 32) {
+ const channel = byOrder.get(current);
+ ordered.push(channel);
+ ordered.push(...sortChannels(channels, channel.id, depth + 1));
+ current = channel.id;
+ }
+ return ordered;
+ }
+
+ function el(tag, className, text) {
+ const node = document.createElement(tag);
+ if (className) node.className = className;
+ if (text !== undefined) node.textContent = text;
+ return node;
+ }
+
+ function safeUrl(path) {
+ return encodeURI(String(path || "")).replace(/["'()\\]/g, (c) => "%" + c.charCodeAt(0).toString(16));
+ }
+
+ function statItem(label, value) {
+ const item = el("div", "stat-item");
+ item.append(el("span", "stat-label", label), el("span", "stat-value", String(value)));
+ return item;
+ }
+
+ const displayName = (name) => name.replace("[Cspacer]", "");
+
+ function formatTime(ts) {
+ try {
+ const d = new Date(ts || Date.now());
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
+ } catch {
+ return "";
+ }
+ }
+
+ function findClientName(clientId, fallback = "") {
+ if (session && session.snapshot) {
+ const found = session.snapshot.clients.find((c) => c.id === clientId);
+ if (found && found.nickname) return found.nickname;
+ }
+ const existing = session && session.dmByUser.get(clientId);
+ if (existing && existing.peerName) return existing.peerName;
+ return fallback || `user #${clientId}`;
+ }
+
+ function ensureDmEntry(peerId, peerName) {
+ let dm = session.dmByUser.get(peerId);
+ const resolvedName = findClientName(peerId, peerName);
+ if (!dm) {
+ dm = { peerId, peerName: resolvedName, messages: [], unread: 0 };
+ session.dmByUser.set(peerId, dm);
+ } else if (resolvedName && dm.peerName !== resolvedName) {
+ dm.peerName = resolvedName;
+ }
+ return dm;
+ }
+
+ function openDmConversation(peerId, peerName) {
+ if (!session || !session.ready || !peerId || peerId === session.selfId) return;
+ const dm = ensureDmEntry(peerId, peerName);
+ dm.unread = 0;
+ session.activeChatTab = { type: "dm", peerId, peerName: dm.peerName };
+ render();
+ const input = ui.container.querySelector(".channel-chat-input");
+ if (input) {
+ input.focus();
+ input.scrollIntoView({ behavior: "smooth", block: "nearest" });
+ }
+ }
+
+ function buildChatMsgRow(m, selfId) {
+ const isSelf = m.invokerId === selfId;
+ const row = el(
+ "div",
+ "channel-chat-msg"
+ + (isSelf ? " is-self" : "")
+ + (m.scope === "private" ? " is-private" : m.scope === "server" ? " is-server" : "")
+ );
+ row.append(el("span", "channel-chat-time", formatTime(m.timestamp)));
+ const authorLabel = m.scope === "server" ? `[server] ${m.invokerName}` : m.invokerName;
+ row.append(el("span", "channel-chat-author", `${authorLabel}:`));
+ row.append(el("span", "channel-chat-text", m.message));
+ return row;
+ }
+
+ function buildChannelChat(channelId, channelTitle) {
+ const wrap = el("section", "channel-chat");
+ wrap.dataset.chatCid = channelId;
+
+ const activeTab = session ? session.activeChatTab : { type: "channel" };
+ const isDmTab = activeTab && activeTab.type === "dm";
+ const activeDm = isDmTab && session ? session.dmByUser.get(activeTab.peerId) : null;
+ if (isDmTab && !activeDm && session) {
+ session.activeChatTab = { type: "channel" };
+ }
+ const showingDm = Boolean(isDmTab && activeDm);
+
+ const head = el("header", "channel-chat-header");
+ const tabs = el("div", "channel-chat-tabs");
+
+ const chTab = el(
+ "button",
+ "channel-chat-tab" + (!showingDm ? " is-active" : ""),
+ `# ${channelTitle}`
+ );
+ chTab.type = "button";
+ chTab.dataset.chatTab = "channel";
+ tabs.append(chTab);
+
+ if (session) {
+ for (const dm of session.dmByUser.values()) {
+ const isCurrentDm = showingDm && activeDm.peerId === dm.peerId;
+ const dmBtn = el(
+ "button",
+ "channel-chat-tab is-dm" + (isCurrentDm ? " is-active" : "") + (dm.unread > 0 ? " has-unread" : ""),
+ dm.unread > 0 ? `@ ${dm.peerName} (${dm.unread})` : `@ ${dm.peerName}`
+ );
+ dmBtn.type = "button";
+ dmBtn.dataset.chatTab = "dm";
+ dmBtn.dataset.dmPeer = String(dm.peerId);
+ tabs.append(dmBtn);
+ }
+ }
+ head.append(tabs);
+
+ const log = el("div", "channel-chat-messages");
+ log.id = "voice-chat-log";
+ if (showingDm) {
+ const msgs = activeDm.messages;
+ if (msgs.length === 0) {
+ log.append(el("div", "channel-chat-empty", `No private messages with ${activeDm.peerName} yet.`));
+ } else {
+ for (const m of msgs) log.append(buildChatMsgRow(m, session.selfId));
+ }
+ } else {
+ const msgs = (session && session.chatByChannel.get(channelId)) || [];
+ if (msgs.length === 0) {
+ log.append(el("div", "channel-chat-empty", "No messages in this channel yet."));
+ } else {
+ for (const m of msgs) log.append(buildChatMsgRow(m, session.selfId));
+ }
+ }
+
+ const draftKey = showingDm ? `dm:${activeDm.peerId}` : `channel:${channelId}`;
+ const form = el("form", "channel-chat-form");
+ form.dataset.chatScope = showingDm ? "private" : "channel";
+ form.dataset.chatCid = channelId;
+ if (showingDm) form.dataset.chatTargetId = String(activeDm.peerId);
+
+ const input = el("input", "channel-chat-input");
+ input.type = "text";
+ input.name = "message";
+ input.maxLength = 1024;
+ input.autocomplete = "off";
+ input.placeholder = showingDm ? `message @${activeDm.peerName}...` : `message #${channelTitle}...`;
+ input.value = (session && session.chatDrafts.get(draftKey)) || "";
+ input.addEventListener("input", () => {
+ if (session) session.chatDrafts.set(draftKey, input.value);
+ });
+ const sendBtn = el("button", "channel-chat-send", "send");
+ sendBtn.type = "submit";
+ form.append(input, sendBtn);
+
+ wrap.append(head, log, form);
+ queueMicrotask(() => { log.scrollTop = log.scrollHeight; });
+ return wrap;
+ }
+
+ function appendChatMessage(msg) {
+ if (!session || !session.snapshot) return;
+
+ if (msg.scope === "private") {
+ const isFromSelf = msg.invokerId === session.selfId;
+ const peerId = isFromSelf ? Number(msg.targetId) : Number(msg.invokerId);
+ if (!peerId) return;
+ const peerName = isFromSelf ? findClientName(peerId) : (msg.invokerName || findClientName(peerId));
+ const dm = ensureDmEntry(peerId, peerName);
+ dm.messages.push(msg);
+ if (dm.messages.length > MAX_CHAT_HISTORY) dm.messages.shift();
+
+ const viewingThisDm = session.activeChatTab
+ && session.activeChatTab.type === "dm"
+ && session.activeChatTab.peerId === peerId;
+
+ if (viewingThisDm) {
+ dm.unread = 0;
+ const log = document.getElementById("voice-chat-log");
+ if (log) {
+ const empty = log.querySelector(".channel-chat-empty");
+ if (empty) empty.remove();
+ log.append(buildChatMsgRow(msg, session.selfId));
+ log.scrollTop = log.scrollHeight;
+ return;
+ }
+ } else if (!isFromSelf) {
+ dm.unread += 1;
+ }
+ render();
+ return;
+ }
+
+ const cid = msg.scope === "channel" && msg.targetId
+ ? String(msg.targetId)
+ : String(session.snapshot.self.channelId);
+ let list = session.chatByChannel.get(cid);
+ if (!list) {
+ list = [];
+ session.chatByChannel.set(cid, list);
+ }
+ list.push(msg);
+ if (list.length > MAX_CHAT_HISTORY) list.shift();
+
+ const viewingChannel = !session.activeChatTab || session.activeChatTab.type === "channel";
+ if (cid !== String(session.snapshot.self.channelId) || !viewingChannel) return;
+ const log = document.getElementById("voice-chat-log");
+ if (!log) return;
+ const empty = log.querySelector(".channel-chat-empty");
+ if (empty) empty.remove();
+ log.append(buildChatMsgRow(msg, session.selfId));
+ log.scrollTop = log.scrollHeight;
+ }
+
+ function render() {
+ if (!session || !session.snapshot) return;
+ const activeEl = document.activeElement;
+ const restoreChatFocus = Boolean(activeEl && activeEl.classList && activeEl.classList.contains("channel-chat-input"));
+ const selectionStart = restoreChatFocus ? activeEl.selectionStart : null;
+ const selectionEnd = restoreChatFocus ? activeEl.selectionEnd : null;
+ const activeVolClid = activeEl && activeEl.classList && activeEl.classList.contains("client-volume-slider")
+ ? activeEl.dataset.volClid
+ : null;
+
+ const snap = session.snapshot;
+ const channels = sortChannels(snap.channels);
+ const realClients = snap.clients.filter((c) => c.type !== 1);
+ const byChannel = new Map();
+ for (const c of realClients) {
+ if (!byChannel.has(c.channelId)) byChannel.set(c.channelId, []);
+ byChannel.get(c.channelId).push(c);
+ if (c.id !== snap.self.id && !session.userVolumes.has(c.id)) {
+ const vol = getUserVolumePercent(c.id, c.nickname);
+ session.userVolumes.set(c.id, vol);
+ if (session.playback) session.playback.setUserVolume(c.id, vol / 100);
+ }
+ if (session.dmByUser.has(c.id)) {
+ session.dmByUser.get(c.id).peerName = c.nickname;
+ }
+ }
+
+ // Stats mirror build_context() in app.py.
+ let afk = 0;
+ let afkChannel = null;
+ for (const ch of snap.channels) if (ch.name.includes("AFK")) afkChannel = ch;
+ if (afkChannel) afk = Math.max(0, snap.clients.filter((c) => c.channelId === afkChannel.id).length - 1);
+ afk += realClients.filter((c) => c.away).length;
+
+ const stats = el("div", "server-stats");
+ stats.append(statItem("Users Connected", realClients.length), statItem("AFK Users", afk));
+
+ const list = el("div", "channel-list");
+ for (const ch of channels) {
+ if (ch.name.includes("[Cspacer]") && !ch.name.includes("AFK")) continue; // spacer
+ const name = displayName(ch.name);
+ const members = byChannel.get(ch.id) || [];
+ const isCurrent = ch.id === snap.self.channelId;
+
+ const article = el("article", "channel" + (isCurrent ? " is-current" : ""));
+ article.dataset.cid = ch.id;
+
+ const header = el("header", "channel-header");
+ const bg = (cfg.channelBanners || {})[name] || (cfg.genericImages || {}).background || "";
+ if (bg) {
+ header.style.backgroundImage =
+ `linear-gradient(90deg, rgba(9, 18, 32, 0.96) 0%, rgba(9, 18, 32, 0.78) 45%, rgba(9, 18, 32, 0.94) 100%), url("${safeUrl(bg)}")`;
+ }
+ const title = el("div", "channel-title-group");
+ const icon = (cfg.channelIcons || {})[name] || (cfg.genericImages || {}).icon || "";
+ if (icon) {
+ const img = el("img", "channel-icon");
+ img.src = safeUrl(icon);
+ img.alt = "";
+ img.setAttribute("aria-hidden", "true");
+ title.append(img);
+ }
+ title.append(el("h2", null, name));
+ if (ch.hasPassword) title.append(el("span", "channel-lock", "pw"));
+
+ const actions = el("div", "channel-actions");
+ actions.append(el("span", "channel-meta", String(members.length)));
+ if (isCurrent) {
+ actions.append(el("span", "channel-here", "joined"));
+ } else {
+ const joinBtn = el("button", "channel-join", "join");
+ joinBtn.type = "button";
+ joinBtn.dataset.cid = ch.id;
+ joinBtn.dataset.name = name;
+ joinBtn.dataset.locked = ch.hasPassword ? "1" : "";
+ joinBtn.setAttribute("aria-label", `Move to ${name}`);
+ actions.append(joinBtn);
+ }
+ header.append(title, actions);
+
+ const ul = el("ul", "client-list");
+ for (const c of members) {
+ const li = el("li", "client");
+ li.dataset.clid = String(c.id);
+ const isSelf = c.id === snap.self.id;
+ if (isSelf) li.classList.add("is-self");
+ if (session.talking.has(c.id)) li.classList.add("is-talking");
+
+ let roleIcon = "";
+ for (const g of c.serverGroups) if (!roleIcon && cfg.roleIcons && cfg.roleIcons[g]) roleIcon = cfg.roleIcons[g];
+ if (roleIcon) {
+ const img = el("img", "role-icon");
+ img.src = safeUrl(roleIcon);
+ img.alt = "Role";
+ li.append(img);
+ } else {
+ li.append(el("span", "client-no-role-icon"));
+ }
+ li.append(el("span", "client-name", c.nickname));
+ if (isSelf) li.append(el("span", "client-you", "(you)"));
+ if (c.away) li.append(el("span", "client-afk", c.awayMessage ? `(AFK: ${c.awayMessage})` : "(AFK)"));
+ if (c.outputMuted) li.append(el("span", "client-muted", "(deafened)"));
+ else if (c.inputMuted) li.append(el("span", "client-muted", "(muted)"));
+
+ if (!isSelf) {
+ const controls = el("div", "client-controls");
+
+ const volPct = getUserVolumePercent(c.id, c.nickname);
+ const volWrap = el("label", "client-volume");
+ volWrap.title = `Volume for ${c.nickname}`;
+ const slider = el("input", "client-volume-slider");
+ slider.type = "range";
+ slider.min = "0";
+ slider.max = "200";
+ slider.step = "5";
+ slider.value = String(volPct);
+ slider.dataset.volClid = String(c.id);
+ slider.dataset.volName = c.nickname;
+ slider.setAttribute("aria-label", `Volume for ${c.nickname}`);
+ const volVal = el("span", "client-volume-value", `${volPct}%`);
+ volWrap.append(slider, volVal);
+
+ const dmEntry = session.dmByUser.get(c.id);
+ const unread = dmEntry ? dmEntry.unread : 0;
+ const isDmOpen = session.activeChatTab
+ && session.activeChatTab.type === "dm"
+ && session.activeChatTab.peerId === c.id;
+ const msgBtn = el(
+ "button",
+ "client-msg-btn" + (isDmOpen ? " is-active" : "") + (unread > 0 ? " has-unread" : ""),
+ unread > 0 ? `msg (${unread})` : "msg"
+ );
+ msgBtn.type = "button";
+ msgBtn.dataset.msgClid = String(c.id);
+ msgBtn.dataset.msgName = c.nickname;
+ msgBtn.title = `Message ${c.nickname}`;
+
+ controls.append(volWrap, msgBtn);
+ li.append(controls);
+ }
+ ul.append(li);
+ }
+ article.append(header, ul);
+ if (isCurrent) {
+ article.append(buildChannelChat(ch.id, name));
+ }
+ list.append(article);
+ }
+
+ ui.container.replaceChildren(stats, list);
+
+ if (restoreChatFocus) {
+ const nextInput = ui.container.querySelector(".channel-chat-input");
+ if (nextInput) {
+ nextInput.focus();
+ try {
+ if (selectionStart !== null && selectionEnd !== null) nextInput.setSelectionRange(selectionStart, selectionEnd);
+ } catch { /* ignore */ }
+ }
+ } else if (activeVolClid) {
+ const nextSlider = ui.container.querySelector(`.client-volume-slider[data-vol-clid="${activeVolClid}"]`);
+ if (nextSlider) nextSlider.focus();
+ }
+
+ const current = snap.channels.find((c) => c.id === snap.self.channelId);
+ ui.channel.textContent = current ? displayName(current.name) : "-";
+ const me = snap.clients.find((c) => c.id === snap.self.id);
+ ui.self.textContent = me ? me.nickname : session.nickname;
+ updateTopbarSummary();
+ }
+
+ // ---- talking indicators -------------------------------------------------------
+
+ function setTalking(clientId, talking) {
+ if (!session) return;
+ const timers = session.talkTimers;
+ clearTimeout(timers.get(clientId));
+ if (talking) {
+ timers.set(clientId, setTimeout(() => setTalking(clientId, false), TALK_TIMEOUT_MS));
+ if (session.talking.has(clientId)) return;
+ session.talking.add(clientId);
+ } else {
+ timers.delete(clientId);
+ if (!session.talking.delete(clientId)) return;
+ }
+ for (const li of ui.container.querySelectorAll(`.client[data-clid="${clientId}"]`)) {
+ li.classList.toggle("is-talking", talking);
+ }
+ }
+
+ // ---- playback (with per-user GainNode) ----------------------------------------
+
+ function createPlayback(ctx, output, getInitialGain) {
+ const speakers = new Map();
+
+ function stream(clientId) {
+ let s = speakers.get(clientId);
+ if (!s) {
+ const gain = ctx.createGain();
+ gain.gain.value = getInitialGain ? getInitialGain(clientId) : 1;
+ gain.connect(output);
+ s = { playTime: 0, timestamp: 0, decoder: null, gain };
+ speakers.set(clientId, s);
+ }
+ return s;
+ }
+
+ function schedule(s, buffer) {
+ const now = ctx.currentTime;
+ if (s.playTime < now) s.playTime = now + START_LATENCY_S;
+ if (s.playTime > now + MAX_AHEAD_S) return;
+ const src = ctx.createBufferSource();
+ src.buffer = buffer;
+ src.connect(s.gain);
+ src.onended = () => src.disconnect();
+ src.start(s.playTime);
+ s.playTime += buffer.duration;
+ }
+
+ function playPcm(clientId, bytes) {
+ const samples = new Int16Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 1);
+ const buffer = ctx.createBuffer(1, samples.length, 48000);
+ const data = buffer.getChannelData(0);
+ for (let i = 0; i < samples.length; i++) data[i] = samples[i] / 32768;
+ schedule(stream(clientId), buffer);
+ }
+
+ function playOpus(clientId, bytes) {
+ const s = stream(clientId);
+ if (!s.decoder || s.decoder.state === "closed") {
+ s.decoder = new AudioDecoder({
+ output: (audio) => {
+ try {
+ const n = audio.numberOfFrames;
+ const buffer = ctx.createBuffer(1, n, audio.sampleRate);
+ const tmp = new Float32Array(n);
+ audio.copyTo(tmp, { planeIndex: 0, format: "f32-planar" });
+ buffer.copyToChannel(tmp, 0);
+ schedule(s, buffer);
+ } catch { /* drop frame */ } finally {
+ audio.close();
+ }
+ },
+ error: () => { s.decoder = null; },
+ });
+ s.decoder.configure({ codec: "opus", sampleRate: 48000, numberOfChannels: 1 });
+ s.timestamp = 0;
+ }
+ if (s.decoder.decodeQueueSize > 5) return;
+ s.decoder.decode(new EncodedAudioChunk({ type: "key", timestamp: s.timestamp, duration: 20000, data: bytes }));
+ s.timestamp += 20000;
+ }
+
+ return {
+ play(kind, clientId, bytes) {
+ try {
+ if (kind === KIND_PCM) playPcm(clientId, bytes);
+ else playOpus(clientId, bytes);
+ } catch { /* ignore broken frames */ }
+ },
+ setUserVolume(clientId, gainValue) {
+ const s = speakers.get(clientId);
+ if (s && s.gain) s.gain.gain.value = gainValue;
+ },
+ close() {
+ for (const s of speakers.values()) {
+ try { s.decoder && s.decoder.close(); } catch { /* closed */ }
+ try { s.gain && s.gain.disconnect(); } catch { /* disconnected */ }
+ }
+ speakers.clear();
+ },
+ };
+ }
+
+ async function browserDecodesOpus() {
+ if (typeof AudioDecoder === "undefined" || typeof EncodedAudioChunk === "undefined") return false;
+ try {
+ const res = await AudioDecoder.isConfigSupported({ codec: "opus", sampleRate: 48000, numberOfChannels: 1 });
+ return Boolean(res.supported);
+ } catch {
+ return false;
+ }
+ }
+
+ // ---- microphone ---------------------------------------------------------------
+
+ function gateOpen(s, rms) {
+ if (s.muted) return false;
+ if (mode === "ptt") return s.pttDown;
+ const now = performance.now();
+ if (levelFromRms(rms) >= threshold()) s.vadUntil = now + VAD_HANG_MS;
+ return now < s.vadUntil;
+ }
+
+ function onMicFrame(s, { samples, rms }) {
+ if (session !== s) return;
+ ui.meter.style.width = `${levelFromRms(rms) * 100}%`;
+ const open = s.ready && gateOpen(s, rms);
+ ui.meter.classList.toggle("is-open", open);
+ if (!open) {
+ if (s.wasTalking) { s.wasTalking = false; setTalking(s.selfId, false); }
+ return;
+ }
+ if (s.ws.readyState !== WebSocket.OPEN || s.ws.bufferedAmount > MAX_SEND_BUFFER) return;
+ const pcm = new Int16Array(samples.length);
+ for (let i = 0; i < samples.length; i++) {
+ const v = Math.max(-1, Math.min(1, samples[i]));
+ pcm[i] = v < 0 ? v * 0x8000 : v * 0x7fff;
+ }
+ s.ws.send(pcm.buffer);
+ s.wasTalking = true;
+ setTalking(s.selfId, true);
+ }
+
+ // ---- session lifecycle --------------------------------------------------------
+
+ function setPhase(phase) {
+ document.body.classList.toggle("voice-connecting", phase === "connecting");
+ document.body.classList.toggle("voice-live", phase === "live");
+ ui.bar.hidden = phase !== "live";
+ ui.submit.disabled = phase === "connecting";
+ }
+
+ async function startJoin(targetChannel = null) {
+ if (session) return;
+ const nickname = ui.nickname.value.trim();
+ const password = ui.password.value;
+ if (nickname.length < 3 || !password) {
+ openLoginModal(targetChannel);
+ return;
+ }
+ if (!window.isSecureContext || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+ return say("Browser voice needs HTTPS (or localhost) for microphone access.", "error");
+ }
+
+ store.set(STORE.nickname, nickname);
+ store.set(STORE.password, password);
+ updateTopbarSummary();
+ setText(ui.notice, "");
+
+ const s = {
+ nickname, target: targetChannel, ws: null, ctx: null, stream: null, worklet: null, playback: null, output: null,
+ ready: false, muted: false, deafened: false, pttDown: false, vadUntil: 0, wasTalking: false,
+ selfId: 0, snapshot: null, talking: new Set(), talkTimers: new Map(),
+ userVolumes: new Map(),
+ chatByChannel: new Map(), dmByUser: new Map(), activeChatTab: { type: "channel" }, chatDrafts: new Map(),
+ closed: false,
+ };
+ session = s;
+ setPhase("connecting");
+ say(`Requesting microphone to join ${targetChannel && targetChannel.name ? targetChannel.name : "voice"}…`);
+
+ try {
+ // The AudioContext must be created inside the click handler's turn.
+ s.ctx = new AudioContext({ latencyHint: "interactive" });
+ const resume = s.ctx.resume();
+ s.stream = await navigator.mediaDevices.getUserMedia({
+ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1 },
+ });
+ await resume;
+ await s.ctx.audioWorklet.addModule("/static/mic-worklet.js");
+ if (s.closed) return;
+
+ const source = s.ctx.createMediaStreamSource(s.stream);
+ s.worklet = new AudioWorkletNode(s.ctx, "ts6-mic-capture", { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [1] });
+ const sink = s.ctx.createGain();
+ sink.gain.value = 0;
+ source.connect(s.worklet).connect(sink).connect(s.ctx.destination);
+ s.worklet.port.onmessage = (e) => onMicFrame(s, e.data);
+
+ s.output = s.ctx.createGain();
+ s.output.gain.value = Number(ui.volume.value) / 100;
+ s.output.connect(s.ctx.destination);
+ s.playback = createPlayback(s.ctx, s.output, (clientId) => {
+ const nick = findClientName(clientId, "");
+ return getUserVolumePercent(clientId, nick) / 100;
+ });
+ } catch (error) {
+ if (s.closed) return;
+ const denied = error && (error.name === "NotAllowedError" || error.name === "SecurityError");
+ teardown(denied ? "Microphone access was denied." : `Could not start audio: ${error && error.message ? error.message : error}`, "error");
+ return;
+ }
+
+ const serverDecode = !(await browserDecodesOpus());
+ if (s.closed) return;
+ say("Connecting to TeamSpeak…");
+
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
+ const ws = new WebSocket(`${proto}//${location.host}${cfg.wsPath || "/ws/voice"}`);
+ ws.binaryType = "arraybuffer";
+ s.ws = ws;
+ let fatalMessage = "";
+ let fatalCode = "";
+
+ ws.onopen = () => {
+ ws.send(JSON.stringify({
+ type: "join",
+ nickname,
+ password,
+ channelId: s.target && s.target.cid ? s.target.cid : undefined,
+ identity: store.get(STORE.identity) || undefined,
+ serverDecode,
+ }));
+ };
+
+ ws.onmessage = (e) => {
+ if (session !== s) return;
+ if (typeof e.data !== "string") {
+ const bytes = new Uint8Array(e.data);
+ if (bytes.length < 3) return;
+ const kind = bytes[0];
+ const clientId = (bytes[1] << 8) | bytes[2];
+ const payload = bytes.subarray(3);
+ if (payload.length === 0) return setTalking(clientId, false);
+ setTalking(clientId, true);
+ s.playback.play(kind, clientId, payload);
+ return;
+ }
+ let msg;
+ try { msg = JSON.parse(e.data); } catch { return; }
+ switch (msg.type) {
+ case "joined":
+ s.ready = true;
+ s.selfId = msg.self.id;
+ s.snapshot = msg;
+ if (msg.identity) store.set(STORE.identity, msg.identity);
+ store.set(STORE.nickname, nickname);
+ store.set(STORE.password, password);
+ updateTopbarSummary();
+ closeLoginModal();
+ setText(ui.message, "");
+ setText(ui.dialogMessage, "");
+ setPhase("live");
+ setText(ui.barMessage, serverDecode ? "Compatibility audio mode (your browser lacks WebCodecs)." : "");
+ render();
+ break;
+ case "state":
+ s.snapshot = msg;
+ s.selfId = msg.self.id;
+ render();
+ break;
+ case "chatMessage":
+ appendChatMessage(msg);
+ break;
+ case "error":
+ if (msg.fatal) {
+ fatalMessage = msg.message;
+ fatalCode = msg.code || "";
+ break;
+ }
+ if (msg.code === "CHANNEL_PASSWORD" && msg.channelId) {
+ promptChannelPassword(msg.channelId);
+ break;
+ }
+ say(msg.message, "error");
+ break;
+ case "disconnected":
+ fatalMessage = msg.reason || "Disconnected.";
+ break;
+ }
+ };
+
+ ws.onclose = (e) => {
+ if (session !== s) return;
+ let reason = fatalMessage;
+ if (!reason) {
+ if (s.ready) reason = e.code === 1006 ? "Connection to the voice gateway was lost." : "Disconnected.";
+ else reason = "Voice gateway unreachable, full, or this origin is not allowed.";
+ }
+ const targetBeforeClose = s.target;
+ teardown(reason, s.ready && !fatalMessage ? "info" : "error");
+ if (fatalCode === "BAD_PASSWORD" || fatalCode === "NICKNAME_INVALID" || fatalCode === "NICKNAME_IN_USE") {
+ openLoginModal(targetBeforeClose, reason);
+ }
+ };
+ }
+
+ function teardown(message, kind = "info") {
+ const s = session;
+ if (!s) return;
+ s.closed = true;
+ const wasReady = s.ready;
+ session = null;
+ for (const t of s.talkTimers.values()) clearTimeout(t);
+ if (s.ws) {
+ s.ws.onclose = null;
+ try {
+ if (s.ws.readyState === WebSocket.OPEN) s.ws.send(JSON.stringify({ type: "leave" }));
+ s.ws.close(1000);
+ } catch { /* closed */ }
+ }
+ if (s.stream) for (const track of s.stream.getTracks()) track.stop();
+ if (s.worklet) s.worklet.port.onmessage = null;
+ if (s.playback) s.playback.close();
+ if (s.ctx) s.ctx.close().catch(() => undefined);
+
+ setPhase("idle");
+ updateTopbarSummary();
+ ui.meter.style.width = "0%";
+ ui.settings.hidden = true;
+ ui.settingsToggle.setAttribute("aria-expanded", "false");
+ setMuteUi(false);
+ setDeafenUi(false);
+ setText(ui.barMessage, "");
+ if (!wasReady && ui.dialog.open) setText(ui.dialogMessage, message, kind);
+ else if (!wasReady) setText(ui.message, message, kind);
+ else setText(ui.notice, message, kind);
+ if (wasReady && typeof window.refreshDashboard === "function") window.refreshDashboard();
+ }
+
+ // ---- channel moves, per-user volume & chat ------------------------------------
+
+ function sendJson(message) {
+ if (session && session.ready && session.ws.readyState === WebSocket.OPEN) session.ws.send(JSON.stringify(message));
+ }
+
+ function channelName(cid) {
+ const ch = session && session.snapshot && session.snapshot.channels.find((c) => c.id === cid);
+ return ch ? displayName(ch.name) : "this channel";
+ }
+
+ function promptChannelPassword(cid) {
+ const password = window.prompt(`Password for ${channelName(cid)}`);
+ if (password === null) return;
+ sendJson({ type: "move", channelId: cid, password });
+ }
+
+ ui.container.addEventListener("input", (e) => {
+ const slider = e.target.closest(".client-volume-slider");
+ if (!slider) return;
+ const clid = Number(slider.dataset.volClid);
+ const name = slider.dataset.volName || "";
+ const pct = setUserVolumePercent(clid, name, slider.value);
+ const label = slider.parentElement && slider.parentElement.querySelector(".client-volume-value");
+ if (label) label.textContent = `${pct}%`;
+ });
+
+ ui.container.addEventListener("click", (e) => {
+ const msgBtn = e.target.closest(".client-msg-btn");
+ if (msgBtn) {
+ const clid = Number(msgBtn.dataset.msgClid);
+ const name = msgBtn.dataset.msgName || "";
+ if (clid) openDmConversation(clid, name);
+ return;
+ }
+
+ const tabBtn = e.target.closest(".channel-chat-tab");
+ if (tabBtn && session && session.ready) {
+ if (tabBtn.dataset.chatTab === "dm") {
+ const peerId = Number(tabBtn.dataset.dmPeer);
+ if (peerId) openDmConversation(peerId);
+ } else {
+ session.activeChatTab = { type: "channel" };
+ render();
+ const input = ui.container.querySelector(".channel-chat-input");
+ if (input) input.focus();
+ }
+ return;
+ }
+
+ const button = e.target.closest(".channel-join");
+ if (!button) return;
+ const cid = button.dataset.cid;
+ if (!session) return void startJoin({ cid, name: button.dataset.name || "" });
+ if (!session.ready) return;
+ setText(ui.barMessage, "");
+ if (button.dataset.locked) return promptChannelPassword(cid);
+ sendJson({ type: "move", channelId: cid });
+ });
+
+ ui.container.addEventListener("submit", (e) => {
+ const form = e.target.closest(".channel-chat-form");
+ if (!form || !session || !session.ready) return;
+ e.preventDefault();
+ const input = form.querySelector(".channel-chat-input");
+ if (!input) return;
+ const text = input.value.trim();
+ if (!text) return;
+ const scope = form.dataset.chatScope || "channel";
+ if (scope === "private") {
+ const targetId = Number(form.dataset.chatTargetId);
+ if (!targetId) return;
+ input.value = "";
+ session.chatDrafts.set(`dm:${targetId}`, "");
+ sendJson({ type: "chat", scope: "private", targetId, message: text });
+ } else {
+ const cid = form.dataset.chatCid;
+ input.value = "";
+ session.chatDrafts.set(`channel:${cid}`, "");
+ sendJson({ type: "chat", scope: "channel", message: text });
+ }
+ });
+
+ // ---- controls -----------------------------------------------------------------
+
+ function setMuteUi(on) {
+ ui.mute.setAttribute("aria-pressed", String(on));
+ ui.mute.textContent = on ? "unmute" : "mute";
+ }
+
+ function setDeafenUi(on) {
+ ui.deafen.setAttribute("aria-pressed", String(on));
+ ui.deafen.textContent = on ? "undeafen" : "deafen";
+ }
+
+ ui.form.addEventListener("submit", (e) => {
+ e.preventDefault();
+ const nickname = ui.nickname.value.trim();
+ const password = ui.password.value;
+ if (nickname.length < 3) return setText(ui.dialogMessage, "Nickname must be 3-30 characters.", "error");
+ if (!password) return setText(ui.dialogMessage, "The server password is required.", "error");
+ store.set(STORE.nickname, nickname);
+ store.set(STORE.password, password);
+ updateTopbarSummary();
+ const targetToJoin = pendingJoin;
+ closeLoginModal();
+ if (targetToJoin !== undefined && !session) {
+ void startJoin(targetToJoin);
+ }
+ });
+
+ ui.leave.addEventListener("click", () => teardown("You left voice."));
+
+ ui.mute.addEventListener("click", () => {
+ if (!session) return;
+ session.muted = !session.muted;
+ setMuteUi(session.muted);
+ sendJson({ type: "mute", muted: session.muted });
+ });
+
+ ui.deafen.addEventListener("click", () => {
+ if (!session) return;
+ session.deafened = !session.deafened;
+ setDeafenUi(session.deafened);
+ if (session.output) session.output.gain.value = session.deafened ? 0 : Number(ui.volume.value) / 100;
+ sendJson({ type: "deafen", deafened: session.deafened });
+ });
+
+ ui.settingsToggle.addEventListener("click", () => {
+ const open = ui.settings.hidden;
+ ui.settings.hidden = !open;
+ ui.settingsToggle.setAttribute("aria-expanded", String(open));
+ });
+
+ ui.mode.addEventListener("click", () => {
+ mode = mode === "ptt" ? "vad" : "ptt";
+ store.set(STORE.mode, mode);
+ applyModeUi();
+ });
+
+ ui.sensitivity.addEventListener("input", () => {
+ store.set(STORE.sensitivity, ui.sensitivity.value);
+ updateThresholdMark();
+ });
+
+ ui.volume.addEventListener("input", () => {
+ store.set(STORE.volume, ui.volume.value);
+ if (session && session.output && !session.deafened) session.output.gain.value = Number(ui.volume.value) / 100;
+ });
+
+ const setPtt = (down) => {
+ if (!session) return;
+ session.pttDown = down;
+ ui.ptt.classList.toggle("is-active", down);
+ };
+ ui.ptt.addEventListener("pointerdown", (e) => { e.preventDefault(); setPtt(true); });
+ for (const ev of ["pointerup", "pointerleave", "pointercancel"]) ui.ptt.addEventListener(ev, () => setPtt(false));
+
+ const typingTarget = (t) => t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName));
+ document.addEventListener("keydown", (e) => {
+ if (e.code === PTT_KEY && mode === "ptt" && session && !e.repeat && !typingTarget(e.target)) setPtt(true);
+ });
+ document.addEventListener("keyup", (e) => { if (e.code === PTT_KEY) setPtt(false); });
+ window.addEventListener("blur", () => setPtt(false));
+
+ window.addEventListener("pagehide", () => { if (session) teardown(""); });
+})();
diff --git a/uv.lock b/uv.lock
index 91d187d..49dbc80 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,5 +1,5 @@
version = 1
-revision = 3
+revision = 5
requires-python = ">=3.13"
[[package]]
@@ -211,6 +211,15 @@ wheels = [
]
[[package]]
+name = "python-dotenv"
+version = "1.2.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" },
+]
+
+[[package]]
name = "requests"
version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
@@ -232,6 +241,7 @@ source = { virtual = "." }
dependencies = [
{ name = "flask" },
{ name = "flask-cors" },
+ { name = "python-dotenv" },
{ name = "requests" },
]
@@ -239,6 +249,7 @@ dependencies = [
requires-dist = [
{ name = "flask", specifier = ">=3.1.3" },
{ name = "flask-cors", specifier = ">=6.0.5" },
+ { name = "python-dotenv", specifier = ">=1.0.1" },
{ name = "requests", specifier = ">=2.34.2" },
]
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage