refactor: port backend and frontend to TypeScript classes (bun + vite)
Backend (src/server, runs on Bun): - GibbzHttpServer (health, /api/ice, /api/rooms, static, keepalive tuning) - SignalingServer with Peer/Room/RoomRegistry classes and typed messages - IceConfigProvider (env STUN/TURN + coturn HMAC ephemeral creds) - StaticFileServer (dist/ with public/ fallback, traversal guard) - Tracing (OpenTelemetry SDK), AppConfig; createApp() factory for tests Frontend (src/client, built by Vite): - VoiceApp orchestrator implementing VoiceAppContext - WebRtcManager + PeerSession (perfect negotiation, pc rebuild, quality monitor) - SignalingClient + ReconnectController, AudioManager, SpatialAudio, Microphone/ScreenShare controllers, ScreensUi/RosterUi/SpatialMap/ SpeakingMonitor, Settings/Welcome dialogs, LayoutManager, MatrixChat Tooling: bun scripts (dev/start/test/typecheck), vite.config.ts root src/client with publicDir public/static, vitest-config for CI-less runs, bun-based Dockerfile (oven/bun:1), Makefile + README updated, legacy server.js/tracing.js/test.js/public js removed. Verified locally: tsc --noEmit clean, vitest run 6/6 green, vite build ok, server smoke-tested serving dist + signaling endpoints under Node. Local bun binary segfaults (SIGILL) on this host, so 'bun test' itself was not executable here; tests are vitest-API compatible which bun test maps.
This commit is contained in:
1 parent
9b8c2a9599
commit
2b950e4953
48 files changed
+4589
-9200
No files matched your search
+18
-11
@@ -1,30 +1,37 @@
|
||||
# gibbz voice — Bun runtime, Vite-built TypeScript client
|
||||
ARG APP_RELEASE=dev
|
||||
|
||||
FROM node:22-alpine AS app
|
||||
|
||||
FROM oven/bun:1 AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
COPY package.json bun.lock* ./
|
||||
RUN bun install
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY server.js tracing.js vite.config.mjs ./
|
||||
COPY public/ ./public/
|
||||
COPY tsconfig.json vite.config.ts ./
|
||||
COPY src ./src
|
||||
COPY public ./public
|
||||
|
||||
# Inject build release into index.html if APP_RELEASE is specified
|
||||
ARG APP_RELEASE
|
||||
ENV APP_RELEASE=${APP_RELEASE}
|
||||
RUN if [ -n "${APP_RELEASE}" ]; then \
|
||||
sed -i "s/window.__APP_RELEASE__ || \"dev\"/\"${APP_RELEASE}\"/g" ./public/index.html; \
|
||||
sed -i "s/window.__APP_RELEASE__ || \"dev\"/\"${APP_RELEASE}\"/g" ./src/client/index.html; \
|
||||
fi
|
||||
|
||||
RUN npm run build && npm prune --omit=dev
|
||||
RUN bun run build && bun install --production
|
||||
|
||||
FROM oven/bun:1
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/src/server ./src/server
|
||||
|
||||
ENV PORT=8080 \
|
||||
HOST=0.0.0.0
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
USER node
|
||||
USER bun
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
CMD ["bun", "src/server/index.ts"]
|
||||
@@ -11,13 +11,15 @@ all: check
|
||||
help: ## Show target help
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
test: ## Run local tests
|
||||
npm test
|
||||
test: ## Run tests (bun test)
|
||||
bun test
|
||||
|
||||
check: test ## Quality gate: run all tests
|
||||
check: test ## Quality gate: tests + typecheck
|
||||
bun run typecheck
|
||||
|
||||
build: check ## Build local dependencies and run tests
|
||||
npm ci
|
||||
build: check ## Install deps and build the client bundle
|
||||
bun install
|
||||
bun run build
|
||||
|
||||
docker-build: ## Build docker image
|
||||
docker build \
|
||||
|
||||
@@ -3,59 +3,80 @@
|
||||
Discord-clone proof of concept: peer-to-peer WebRTC voice chat + screen sharing
|
||||
with position-based spatial audio and pluggable STUN/TURN.
|
||||
|
||||
Run (Node 18+, only dep is `ws`):
|
||||
TypeScript everywhere: the backend runs on [Bun](https://bun.sh), the client is
|
||||
built with [Vite](https://vite.dev), and both are organized into classes.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/server/ Bun (Node-compatible) WebSocket/HTTP backend
|
||||
index.ts entrypoint + createApp() factory (used by tests)
|
||||
http-server.ts GibbzHttpServer — health probes, /api/ice, /api/rooms, static
|
||||
signaling.ts Peer / Room / RoomRegistry / SignalingServer (ws)
|
||||
ice.ts IceConfigProvider — env-driven STUN/TURN + HMAC TURN creds
|
||||
static-files.ts StaticFileServer — dist/ (or public/) with traversal guard
|
||||
tracing.ts Tracing — OpenTelemetry SDK setup
|
||||
config.ts AppConfig — env + static dir resolution
|
||||
|
||||
src/client/ Vite root (index.html, style.css)
|
||||
app.ts VoiceApp — orchestrator implementing VoiceAppContext
|
||||
context.ts the subsystem-facing application interface
|
||||
state.ts AppState — shared cross-cutting voice state
|
||||
webrtc/ WebRtcManager, PeerSession, SignalingClient, ReconnectController
|
||||
media/ AudioManager (mic chain), SpatialAudio, Microphone-, ScreenShareController
|
||||
ui/ roster/screens/spatial-map renderers, settings, welcome, layout
|
||||
chat/ MatrixChat (OAuth2/OIDC text channel)
|
||||
|
||||
public/static/ PWA assets served verbatim (icons, manifest, service worker)
|
||||
tests/ bun test / vitest compatible suite (tests/server.test.ts)
|
||||
```
|
||||
|
||||
## Develop
|
||||
|
||||
```sh
|
||||
npm install
|
||||
PORT=8081 npm start
|
||||
# open http://localhost:8081, click a voice channel, allow mic
|
||||
bun install
|
||||
bun run dev:server # backend on :8080 (bun --watch)
|
||||
bun run dev # vite dev server for the client
|
||||
bun test # tests (vitest-compatible; `bun run test:node` = vitest run)
|
||||
bun run typecheck # tsc --noEmit
|
||||
bun run build # production client bundle into dist/
|
||||
bun run start # serve dist/ + signaling (bun src/server/index.ts)
|
||||
```
|
||||
|
||||
The server also runs under Node 22.18+/23+ (`node src/server/index.ts`) since it
|
||||
only uses erasable TypeScript, but Bun is the supported runtime (the Dockerfile
|
||||
is `oven/bun:1`). A `bun.lock` is generated on first `bun install`.
|
||||
|
||||
## What works
|
||||
|
||||
- Mesh WebRTC voice (perfect negotiation; lowest peer id initiates)
|
||||
- Screen share with audio (mic is spatialized, screen audio plays direct)
|
||||
- Mesh WebRTC voice (perfect negotiation; lowest peer id initiates), full
|
||||
pc-rebuild recovery on ICE failure with TURN-relay fallback
|
||||
- Screen share with audio (mic is spatialized, screen audio plays direct),
|
||||
spotlight/grid tiles and pop-out windows
|
||||
- Spatial audio: drag your avatar on the plane; remote mics run through a
|
||||
`PannerNode` (HRTF, inverse distance). Positions sync over signaling.
|
||||
- Hi-Fi rooms (Code, Gaming): stereo Opus 256 kbps, echo cancellation off
|
||||
- Custom STUN/TURN: gear icon → JSON override in `localStorage`, applied on
|
||||
next join. Server default comes from env (below).
|
||||
- Mute (M), deafen (D), per-peer volume, speaking indicators.
|
||||
- Voice capture uses mono 48 kHz Opus-oriented settings, a 64 kbps audio ceiling,
|
||||
and periodic packet-loss, jitter, and RTT quality diagnostics.
|
||||
- Audio input has manual gain control and a live signal/clipping meter in the
|
||||
settings dialog; microphone processing is split into `public/audio-manager.js`.
|
||||
- Screen sharing uses the browser's default codec negotiation, with selectable
|
||||
`detail` (text/UI) or `motion` (gameplay) content hints.
|
||||
- Mute (M), deafen (D), per-peer volume, speaking indicators, quality
|
||||
diagnostics (loss/jitter/RTT), auto-reconnect with countdown banner
|
||||
- Matrix-backed text channel (OAuth2/OIDC against matrix.k6n.net)
|
||||
|
||||
## ICE configuration (server side)
|
||||
|
||||
| Env var | Meaning |
|
||||
|---|---|
|
||||
| `STUN_URLS` | comma-separated `stun:` urls (Google defaults) |
|
||||
| `STUN_URLS` | comma-separated `stun:` urls |
|
||||
| `TURN_URLS` | comma-separated `turn:`/`turns:` urls |
|
||||
| `TURN_USERNAME` / `TURN_CREDENTIAL` | static TURN creds |
|
||||
| `TURN_SECRET` | coturn static-auth-secret → ephemeral HMAC creds |
|
||||
| `ICE_TRANSPORT_POLICY` | WebRTC ICE policy: `all`, `relay`, or `none` |
|
||||
| `ICE_SERVERS_JSON` | full `{"iceServers":[...]}` override, wins over the above |
|
||||
| `PORT` / `HOST` | listen address (default 8081 / 0.0.0.0) |
|
||||
|
||||
Example with coturn:
|
||||
|
||||
```sh
|
||||
TURN_URLS='turn:turn.example.com:3478?transport=udp,turns:turn.example.com:5349' \
|
||||
TURN_USERNAME=mats TURN_CREDENTIAL=secret npm start
|
||||
```
|
||||
|
||||
For deployments where clients are commonly behind separate NATs, set
|
||||
`ICE_TRANSPORT_POLICY=relay` so media consistently uses the public TURN
|
||||
service instead of depending on NAT hairpinning or direct candidate pairs.
|
||||
| `PORT` / `HOST` | listen address (default 8080 / 0.0.0.0) |
|
||||
|
||||
## POC shortcuts (productionize later)
|
||||
|
||||
- No auth, no reconnection, rooms are in-memory, signaling is unencrypted ws
|
||||
behind the preview ingress (use wss/TLS in front for real deploys).
|
||||
- No media server: N² mesh — fine for small rooms, needs SFU (mediasoup/livekit)
|
||||
beyond ~6–8 participants.
|
||||
- For larger rooms or consistently lower bandwidth, replace the mesh with an SFU;
|
||||
the current quality tuning improves the P2P path but does not change its N² cost.
|
||||
- Text channels are decorative. No persistence, no rate limiting.
|
||||
- Screen-share negotiation storms on join/leave are expected at small scale.
|
||||
- No auth, rooms are in-memory, signaling is plain ws behind the ingress.
|
||||
- No media server: N² mesh — fine for small rooms, needs SFU beyond ~6–8
|
||||
participants.
|
||||
- Text channels are a single live Matrix room; no history pagination.
|
||||
Generated
-6036
File diff suppressed because it is too large.
Load diff
+13
-3
@@ -1,12 +1,18 @@
|
||||
{
|
||||
"name": "Gameflow",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"description": "WebRTC voice chat + screen sharing + spatial audio",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:server": "bun --watch src/server/index.ts",
|
||||
"build": "vite build",
|
||||
"start": "node server.js",
|
||||
"test": "node --test"
|
||||
"start": "bun src/server/index.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bun test",
|
||||
"test:node": "vitest run",
|
||||
"check": "bun run typecheck && bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
@@ -22,10 +28,14 @@
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.2.0",
|
||||
"@types/ws": "^8.18.0",
|
||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||
"esbuild": "^0.28.2",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^8.3.0",
|
||||
"vite-plugin-node-polyfills": "^0.28.0"
|
||||
"vite-plugin-node-polyfills": "^0.28.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild@0.28.2": true
|
||||
|
||||
-2546
File diff suppressed because it is too large.
Load diff
@@ -1,331 +0,0 @@
|
||||
// Minimal WebRTC signaling + static file server for the Discord-clone POC.
|
||||
// Load OpenTelemetry tracing and logging before HTTP / network modules
|
||||
require('./tracing');
|
||||
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { WebSocketServer } = require('ws');
|
||||
const { trace } = require('@opentelemetry/api');
|
||||
|
||||
function traceId() {
|
||||
return trace.getActiveSpan()?.spanContext().traceId;
|
||||
}
|
||||
|
||||
const PORT = Number(process.env.PORT || 8080);
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
const STATIC_DIR = fs.existsSync(path.join(__dirname, 'dist'))
|
||||
? path.join(__dirname, 'dist')
|
||||
: path.join(__dirname, 'public');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ICE configuration: env-driven, full JSON override wins.
|
||||
// STUN_URLS comma-separated stun: urls
|
||||
// TURN_URLS comma-separated turn:/turns: urls
|
||||
// TURN_USERNAME / TURN_CREDENTIAL static creds for all TURN urls
|
||||
// ICE_TRANSPORT_POLICY optional WebRTC policy (all, relay, or none)
|
||||
// TURN_LAN_HOST / TURN_LAN_SECRET optional second, LAN-only TURN relay
|
||||
// (own coturn instance + secret, reachable only from
|
||||
// the local network via its own LoadBalancer IP with
|
||||
// no NAT/hairpin involved). Harmless to hand out to
|
||||
// every client: anyone not on that network simply
|
||||
// can't reach the address and ICE ignores it.
|
||||
// ICE_SERVERS_JSON complete {"iceServers":[...]} override
|
||||
// ---------------------------------------------------------------------------
|
||||
function hmacTurnCredential(secret, label, ttl) {
|
||||
const expiry = Math.floor(Date.now() / 1000) + ttl;
|
||||
const username = `${expiry}:${label}`;
|
||||
const hmac = crypto.createHmac('sha1', secret);
|
||||
hmac.update(username);
|
||||
return { username, credential: hmac.digest('base64') };
|
||||
}
|
||||
|
||||
function iceConfig() {
|
||||
if (process.env.ICE_SERVERS_JSON) {
|
||||
try {
|
||||
const parsed = JSON.parse(process.env.ICE_SERVERS_JSON);
|
||||
if (Array.isArray(parsed.iceServers)) return parsed;
|
||||
console.error('ICE_SERVERS_JSON missing iceServers array, falling back to env vars');
|
||||
} catch (err) {
|
||||
console.error('ICE_SERVERS_JSON invalid JSON:', err.message);
|
||||
}
|
||||
}
|
||||
const urls = (s) => (s || '').split(',').map((x) => x.trim()).filter(Boolean);
|
||||
const stunUrls = urls(process.env.STUN_URLS || 'stun:turn.k6n.net:3478,stun:stun.l.google.com:19302');
|
||||
const iceServers = stunUrls.map((u) => ({ urls: u }));
|
||||
|
||||
const turnUrls = urls(process.env.TURN_URLS || 'turn:turn.k6n.net:3478?transport=udp,turn:turn.k6n.net:3478?transport=tcp,turns:turn.k6n.net:5349?transport=tcp');
|
||||
if (turnUrls.length) {
|
||||
let username = process.env.TURN_USERNAME || '';
|
||||
let credential = process.env.TURN_CREDENTIAL || process.env.TURN_PASSWORD || '';
|
||||
|
||||
// If a TURN secret is provided (coturn use-auth-secret / static-auth-secret), generate ephemeral HMAC credentials
|
||||
const turnSecret = process.env.TURN_SECRET || process.env.TURN_STATIC_AUTH_SECRET || '';
|
||||
const ttl = Number(process.env.TURN_TTL || 86400);
|
||||
if (turnSecret) {
|
||||
({ username, credential } = hmacTurnCredential(turnSecret, 'gibbz', ttl));
|
||||
}
|
||||
|
||||
iceServers.push({
|
||||
urls: turnUrls,
|
||||
username,
|
||||
credential,
|
||||
});
|
||||
|
||||
if (process.env.TURN_LAN_HOST && process.env.TURN_LAN_SECRET) {
|
||||
const { username: lanUsername, credential: lanCredential } =
|
||||
hmacTurnCredential(process.env.TURN_LAN_SECRET, 'gibbz-lan', ttl);
|
||||
iceServers.push({
|
||||
urls: [
|
||||
`turn:${process.env.TURN_LAN_HOST}:3478?transport=udp`,
|
||||
`turn:${process.env.TURN_LAN_HOST}:3478?transport=tcp`,
|
||||
],
|
||||
username: lanUsername,
|
||||
credential: lanCredential,
|
||||
});
|
||||
}
|
||||
}
|
||||
const config = { iceServers };
|
||||
const iceTransportPolicy = process.env.ICE_TRANSPORT_POLICY;
|
||||
if (iceTransportPolicy && ['all', 'relay', 'none'].includes(iceTransportPolicy)) {
|
||||
config.iceTransportPolicy = iceTransportPolicy;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static files
|
||||
// ---------------------------------------------------------------------------
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json',
|
||||
'.webmanifest': 'application/manifest+json',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.ico': 'image/x-icon',
|
||||
};
|
||||
|
||||
function serveStatic(req, res) {
|
||||
const urlPath = decodeURIComponent(new URL(req.url, 'http://x').pathname);
|
||||
let rel = urlPath === '/' ? '/index.html' : urlPath;
|
||||
const file = path.normalize(path.join(STATIC_DIR, rel));
|
||||
if (!file.startsWith(STATIC_DIR)) {
|
||||
res.writeHead(403).end('forbidden');
|
||||
return;
|
||||
}
|
||||
fs.readFile(file, (err, data) => {
|
||||
if (err) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' }).end('not found');
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-cache' });
|
||||
res.end(data);
|
||||
});
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const start = process.hrtime.bigint();
|
||||
const { method, url } = req;
|
||||
res.on('close', () => {
|
||||
const ms = Number(process.hrtime.bigint() - start) / 1e6;
|
||||
// res.writableEnded false here means the client (or ingress) dropped the
|
||||
// connection before we finished writing — the smoking gun for a 502 that
|
||||
// originates on the proxy side rather than in this process.
|
||||
if (!res.writableEnded) {
|
||||
console.warn(`[http] ABORTED ${method} ${url} after ${ms.toFixed(1)}ms (status=${res.statusCode}) trace=${traceId()}`);
|
||||
} else if (ms > 1000) {
|
||||
console.warn(`[http] SLOW ${method} ${url} ${res.statusCode} ${ms.toFixed(1)}ms trace=${traceId()}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (req.url === '/healthz' || req.url === '/readyz' || req.url === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('ok');
|
||||
return;
|
||||
}
|
||||
if (req.url.startsWith('/api/ice')) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(iceConfig()));
|
||||
return;
|
||||
}
|
||||
if (req.url.startsWith('/api/rooms')) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' });
|
||||
res.end(JSON.stringify(allRoomsSnapshot()));
|
||||
return;
|
||||
}
|
||||
serveStatic(req, res);
|
||||
});
|
||||
|
||||
// Node's default keepAliveTimeout (5s) is shorter than ingress-nginx's default
|
||||
// upstream keepalive idle timeout (60s). Under that mismatch, nginx can pick a
|
||||
// pooled connection Node has *just* closed and forward a request onto it,
|
||||
// which comes back to the browser as a 502 — invisible from inside this
|
||||
// process because Node never saw the request. headersTimeout must stay above
|
||||
// keepAliveTimeout (Node enforces this).
|
||||
server.keepAliveTimeout = 65_000;
|
||||
server.headersTimeout = 66_000;
|
||||
|
||||
server.on('clientError', (err, socket) => {
|
||||
console.warn(`[http] clientError: ${err.message} (code=${err.code})`);
|
||||
if (socket.writable) socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signaling
|
||||
// client -> server: {type: offer|answer|candidate, to} {type: pos|state}
|
||||
// server -> client: {type: hello, id, peers} {type: joined|left, ...}
|
||||
// relayed messages get "from" stamped
|
||||
// ---------------------------------------------------------------------------
|
||||
const rooms = new Map(); // roomId -> Map(peerId -> client)
|
||||
|
||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||
|
||||
function roomPeers(roomId) {
|
||||
let room = rooms.get(roomId);
|
||||
if (!room) {
|
||||
room = new Map();
|
||||
rooms.set(roomId, room);
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
function broadcast(room, msg, exceptId) {
|
||||
const data = JSON.stringify(msg);
|
||||
for (const [id, client] of room) {
|
||||
if (id !== exceptId && client.socket.readyState === 1) client.socket.send(data);
|
||||
}
|
||||
}
|
||||
|
||||
function allRoomsSnapshot() {
|
||||
const out = {};
|
||||
for (const [rId, r] of rooms.entries()) {
|
||||
out[rId] = [...r.values()].map(snapshot);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function broadcastRosters() {
|
||||
const snap = allRoomsSnapshot();
|
||||
const data = JSON.stringify({ type: 'rosters', rooms: snap });
|
||||
for (const client of wss.clients) {
|
||||
if (client.readyState === 1) {
|
||||
client.send(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wss.on('connection', (socket, req) => {
|
||||
const q = new URL(req.url, 'http://x').searchParams;
|
||||
const roomId = (q.get('room') || 'general').slice(0, 64);
|
||||
const name = (q.get('name') || 'anon').slice(0, 32);
|
||||
const id = crypto.randomBytes(4).toString('hex');
|
||||
const room = roomPeers(roomId);
|
||||
const client = { socket, id, name, x: 50, y: 50, state: { muted: false, deafened: false, sharing: false } };
|
||||
room.set(id, client);
|
||||
|
||||
const send = (msg) => {
|
||||
if (socket.readyState === 1) socket.send(JSON.stringify(msg));
|
||||
};
|
||||
|
||||
send({ type: 'hello', id, room: roomId, peers: [...room.values()].filter((p) => p.id !== id).map(snapshot) });
|
||||
broadcast(room, { type: 'joined', peer: snapshot(client) }, id);
|
||||
broadcastRosters();
|
||||
console.log(`[signaling] ${name} (${id}) joined #${roomId} — ${room.size} in room`);
|
||||
|
||||
socket.on('message', (raw) => {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!msg || typeof msg.type !== 'string') return;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'ping': {
|
||||
send({ type: 'pong' });
|
||||
break;
|
||||
}
|
||||
case 'pos': {
|
||||
const x = Number(msg.x), y = Number(msg.y);
|
||||
if (Number.isFinite(x) && Number.isFinite(y)) {
|
||||
client.x = Math.min(100, Math.max(0, x));
|
||||
client.y = Math.min(100, Math.max(0, y));
|
||||
broadcast(room, { type: 'pos', from: id, x: client.x, y: client.y }, id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'state': {
|
||||
client.state = {
|
||||
muted: !!msg.state?.muted,
|
||||
deafened: !!msg.state?.deafened,
|
||||
sharing: !!msg.state?.sharing,
|
||||
};
|
||||
broadcast(room, { type: 'state', from: id, state: client.state }, id);
|
||||
broadcastRosters();
|
||||
break;
|
||||
}
|
||||
case 'offer':
|
||||
case 'answer':
|
||||
case 'candidate':
|
||||
case 'reset': {
|
||||
if (!msg.to || !room.has(msg.to)) return;
|
||||
const target = room.get(msg.to);
|
||||
if (target.socket.readyState === 1) {
|
||||
const { to: _to, ...rest } = msg;
|
||||
target.socket.send(JSON.stringify({ ...rest, from: id }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break; // ignore unknown types — forward compat
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
room.delete(id);
|
||||
if (room.size === 0) rooms.delete(roomId);
|
||||
else broadcast(room, { type: 'left', id });
|
||||
broadcastRosters();
|
||||
console.log(`[signaling] ${name} (${id}) left #${roomId} — ${room.size} in room`);
|
||||
});
|
||||
});
|
||||
|
||||
function snapshot(c) {
|
||||
return { id: c.id, name: c.name, x: c.x, y: c.y, state: c.state };
|
||||
}
|
||||
|
||||
// heartbeat: drop dead sockets
|
||||
const interval = setInterval(() => {
|
||||
for (const room of rooms.values()) {
|
||||
for (const [, client] of room) {
|
||||
if (client.socket.readyState === 1) {
|
||||
client.socket.ping();
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
wss.on('close', () => clearInterval(interval));
|
||||
|
||||
function startServer(port = PORT, host = HOST) {
|
||||
return new Promise((resolve) => {
|
||||
server.listen(port, host, () => {
|
||||
const ice = iceConfig();
|
||||
console.log(`voice-poc listening on http://${host}:${port}`);
|
||||
console.log(`ICE servers: ${JSON.stringify(ice.iceServers)}`);
|
||||
resolve(server);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
startServer();
|
||||
}
|
||||
|
||||
module.exports = { server, wss, iceConfig, allRoomsSnapshot, interval, startServer };
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { rumTraceHeaders } from './rum';
|
||||
import type { PeerSnapshot, RtcIceConfig } from './types';
|
||||
|
||||
/** Thin client for the JSON API endpoints. */
|
||||
export class VoiceApiClient {
|
||||
async fetchIce(): Promise<RtcIceConfig> {
|
||||
const res = await fetch('/api/ice', { headers: rumTraceHeaders() });
|
||||
if (!res.ok) throw new Error(`ice ${res.status}`);
|
||||
return (await res.json()) as RtcIceConfig;
|
||||
}
|
||||
|
||||
async fetchRooms(): Promise<Record<string, PeerSnapshot[]>> {
|
||||
const res = await fetch('/api/rooms', { headers: rumTraceHeaders() });
|
||||
if (!res.ok) throw new Error(`rooms ${res.status}`);
|
||||
return (await res.json()) as Record<string, PeerSnapshot[]>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
import { VoiceApiClient } from './api';
|
||||
import { HIFI_ROOMS, LS_NAME } from './constants';
|
||||
import { AudioManager } from './media/audio-manager';
|
||||
import { MicrophoneController } from './media/microphone';
|
||||
import { ScreenShareController } from './media/screen-share';
|
||||
import { SpatialAudio } from './media/spatial';
|
||||
import { captureRumIssue } from './rum';
|
||||
import { AppState } from './state';
|
||||
import { SignalingClient, ReconnectController } from './webrtc/signaling-client';
|
||||
import { WebRtcManager } from './webrtc/web-rtc-manager';
|
||||
import { collectElements, collectMirrorButtons, mIcon, toast } from './ui/elements';
|
||||
import { LayoutManager } from './ui/layout';
|
||||
import { RosterUi, setAvatar } from './ui/roster-ui';
|
||||
import { ScreensUi } from './ui/screens-ui';
|
||||
import { SettingsDialog } from './ui/settings-dialog';
|
||||
import { SpeakingMonitor } from './ui/speaking-monitor';
|
||||
import { SpatialMap } from './ui/spatial-map';
|
||||
import { WelcomeDialog } from './ui/welcome-dialog';
|
||||
import { MatrixChat } from './chat/matrix-chat';
|
||||
import type { AudioLevel, ClientSignal, IcePathInfo, RetroCue, ServerSignal } from './types';
|
||||
import type { PeerSession } from './webrtc/peer-session';
|
||||
import type { VoiceAppContext } from './context';
|
||||
|
||||
/**
|
||||
* VoiceApp — the orchestrator implementing VoiceAppContext. Joins/leaves
|
||||
* channels, dispatches signaling messages and wires every subsystem together.
|
||||
*/
|
||||
export class VoiceApp implements VoiceAppContext {
|
||||
readonly state = new AppState();
|
||||
readonly audio = new AudioManager();
|
||||
readonly spatial = new SpatialAudio(this.state, this.audio);
|
||||
readonly els = collectElements();
|
||||
readonly api = new VoiceApiClient();
|
||||
|
||||
mic!: MicrophoneController;
|
||||
webrtc!: WebRtcManager;
|
||||
signaling!: SignalingClient;
|
||||
screens!: ScreensUi;
|
||||
screenShare!: ScreenShareController;
|
||||
roster!: RosterUi;
|
||||
|
||||
private readonly mirror = collectMirrorButtons();
|
||||
private readonly monitor: SpeakingMonitor;
|
||||
private readonly reconnect: ReconnectController;
|
||||
private readonly spatialMap: SpatialMap | null;
|
||||
private readonly layout: LayoutManager;
|
||||
private readonly settings: SettingsDialog;
|
||||
private readonly welcome: WelcomeDialog;
|
||||
private readonly matrix: MatrixChat;
|
||||
private lastPosSend = 0;
|
||||
private resumeCtx = (): void => {
|
||||
if (this.state.audioCtx && this.state.audioCtx.state === 'suspended') {
|
||||
this.state.audioCtx.resume().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this.webrtc = new WebRtcManager(this);
|
||||
this.mic = new MicrophoneController(this);
|
||||
this.signaling = new SignalingClient(this);
|
||||
this.screens = new ScreensUi(this);
|
||||
this.screenShare = new ScreenShareController(this);
|
||||
this.screenShare.popout = (share) => this.screens.popOutShare(share);
|
||||
this.roster = new RosterUi(this);
|
||||
this.monitor = new SpeakingMonitor(this);
|
||||
this.reconnect = new ReconnectController(this, {
|
||||
banner: this.els.reconnectBanner,
|
||||
text: this.els.reconnectText,
|
||||
retry: document.querySelector('#reconnect-retry'),
|
||||
dismiss: document.querySelector('#reconnect-dismiss'),
|
||||
});
|
||||
this.spatialMap = this.els.spatial ? new SpatialMap(this, this.els.spatial) : null;
|
||||
this.layout = new LayoutManager(this.els, {
|
||||
onSpatialResize: () => this.spatialMap?.resize(),
|
||||
onSpatialExpand: () => this.spatialMap?.resize(),
|
||||
applyGridLayout: () => this.screens.applyGridLayout(),
|
||||
closeSidebar: () => this.layout.closeSidebar(),
|
||||
});
|
||||
this.settings = new SettingsDialog(this);
|
||||
this.welcome = new WelcomeDialog(this);
|
||||
this.matrix = new MatrixChat(this, this.els, {
|
||||
switchToVoiceView: () => this.matrix.switchToVoiceView(),
|
||||
resizeSpatial: () => this.spatialMap?.resize(),
|
||||
});
|
||||
|
||||
this.wire();
|
||||
(window as unknown as { __dbg: unknown }).__dbg = this.state; // POC debug handle
|
||||
}
|
||||
|
||||
// --- VoiceAppContext ------------------------------------------------------
|
||||
|
||||
el(id: 'screens'): HTMLElement | null {
|
||||
return id === 'screens' ? this.els.screens : null;
|
||||
}
|
||||
|
||||
get roomIsHifiRoom(): boolean {
|
||||
return HIFI_ROOMS.has(this.state.room || '');
|
||||
}
|
||||
|
||||
roomIsHifi(room?: string | null): boolean {
|
||||
return HIFI_ROOMS.has(room ?? this.state.room ?? '');
|
||||
}
|
||||
|
||||
send(msg: ClientSignal): void {
|
||||
this.signaling.send(msg);
|
||||
}
|
||||
|
||||
sendState(): void {
|
||||
const s = this.state;
|
||||
this.send({ type: 'state', state: { muted: s.muted, deafened: s.deafened, sharing: s.sharing } });
|
||||
}
|
||||
|
||||
toast(msg: string, kind = ''): void {
|
||||
toast(msg, kind);
|
||||
}
|
||||
|
||||
setStatus(text: string, state = ''): void {
|
||||
// Concise display text
|
||||
let shortText = text;
|
||||
if (text.startsWith('voice connected')) {
|
||||
shortText = this.state.room ? `#${this.state.room}` : 'Connected';
|
||||
} else if (text.startsWith('connecting to')) {
|
||||
shortText = 'Connecting…';
|
||||
}
|
||||
for (const el of this.mirror.statusTextEls) el.textContent = shortText;
|
||||
if (this.els.statusRow) this.els.statusRow.title = text;
|
||||
for (const el of this.mirror.statusDotEls) el.className = 'status-indicator' + (state ? ` ${state}` : '');
|
||||
}
|
||||
|
||||
renderAll(): void {
|
||||
this.roster.renderTiles();
|
||||
this.screens.render();
|
||||
this.renderStageEmpty();
|
||||
this.roster.renderChannelCounts();
|
||||
}
|
||||
|
||||
renderTiles(): void {
|
||||
this.roster.renderTiles();
|
||||
}
|
||||
|
||||
renderScreens(): void {
|
||||
this.screens.render();
|
||||
}
|
||||
|
||||
renderChannelCounts(): void {
|
||||
this.roster.renderChannelCounts();
|
||||
}
|
||||
|
||||
renderStageEmpty(): void {
|
||||
const hasScreens = this.screens.activeShares().length > 0;
|
||||
this.els.stage?.classList.toggle('empty', !hasScreens);
|
||||
}
|
||||
|
||||
updatePanner(peer: PeerSession): void {
|
||||
this.spatial.updatePanner(peer);
|
||||
}
|
||||
|
||||
playCue(name: RetroCue): void {
|
||||
this.spatial.playCue(name);
|
||||
}
|
||||
|
||||
notifyIceIssue(peer: PeerSession | null, message: string, extra: Record<string, unknown> = {}): void {
|
||||
this.webrtc.notifyIceIssue(peer, message, extra);
|
||||
}
|
||||
|
||||
async icePathOf(pc: RTCPeerConnection): Promise<IcePathInfo | null> {
|
||||
return this.webrtc.selectedIcePath(pc);
|
||||
}
|
||||
|
||||
isMeSpeaking(): boolean {
|
||||
return this.els.meAvatar?.classList.contains('speaking') ?? false;
|
||||
}
|
||||
|
||||
setMeAvatarSpeaking(on: boolean): void {
|
||||
this.els.meAvatar?.classList.toggle('speaking', on);
|
||||
}
|
||||
|
||||
updateMicSignal(level: AudioLevel = this.audio.getLevel()): void {
|
||||
const meter = document.querySelector<HTMLMeterElement>('#mic-level');
|
||||
const value = document.querySelector<HTMLElement>('#mic-level-value');
|
||||
const clipping = document.querySelector<HTMLElement>('#mic-clipping');
|
||||
if (meter) meter.value = Math.min(1, level.rms * 3);
|
||||
if (value) value.textContent = level.rms > 0 ? `${Math.round(20 * Math.log10(level.rms))} dB` : '-∞ dB';
|
||||
if (clipping) clipping.classList.toggle('hidden', !level.clipping);
|
||||
}
|
||||
|
||||
updateMicButton(): void {
|
||||
const off = this.state.muted || !this.state.micStream;
|
||||
for (const btn of this.mirror.micButtons) {
|
||||
btn.classList.toggle('off', off);
|
||||
btn.replaceChildren(mIcon(off ? 'mic_off' : 'mic'));
|
||||
btn.disabled = !this.state.micStream;
|
||||
}
|
||||
}
|
||||
|
||||
updateDeafenButton(): void {
|
||||
for (const btn of this.mirror.deafenButtons) {
|
||||
btn.classList.toggle('off', this.state.deafened);
|
||||
btn.replaceChildren(mIcon(this.state.deafened ? 'headset_off' : 'headphones'));
|
||||
}
|
||||
}
|
||||
|
||||
updateMicGainUi(): void {
|
||||
this.settings.updateMicGainUi();
|
||||
}
|
||||
|
||||
async fetchIce() {
|
||||
return this.api.fetchIce();
|
||||
}
|
||||
|
||||
async fetchRooms(): Promise<void> {
|
||||
try {
|
||||
this.state.channelRosters = await this.api.fetchRooms();
|
||||
this.renderTiles();
|
||||
this.renderChannelCounts();
|
||||
} catch (err) {
|
||||
console.warn('fetchRooms failed', err);
|
||||
captureRumIssue('Could not refresh voice channel rosters', 'WARNING', { error: (err as Error)?.message || String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
beginReconnect(room: string): void {
|
||||
this.reconnect.begin(room);
|
||||
}
|
||||
|
||||
cancelReconnect(): void {
|
||||
this.reconnect.cancel();
|
||||
}
|
||||
|
||||
sendMyPos(force = false): void {
|
||||
const now = performance.now();
|
||||
if (!force && now - this.lastPosSend < 60) return;
|
||||
this.lastPosSend = now;
|
||||
this.send({ type: 'pos', x: this.state.me.x, y: this.state.me.y });
|
||||
for (const peer of this.state.peers.values()) this.updatePanner(peer);
|
||||
}
|
||||
|
||||
// --- join / leave -----------------------------------------------------------
|
||||
|
||||
async joinVoice(room: string): Promise<void> {
|
||||
const s = this.state;
|
||||
if (s.room === room || s.connecting) return;
|
||||
if (s.room) this.leaveVoice('switching channel');
|
||||
s.connecting = true;
|
||||
this.setStatus(`connecting to ${room}…`, 'connecting');
|
||||
// Hi-Fi profile must be resolved before capture: s.room still names the
|
||||
// previous channel here, so the target room is passed explicitly.
|
||||
const hifi = HIFI_ROOMS.has(room);
|
||||
// Select the gain profile before capture: Hi-Fi rooms run raw (no AGC/echo
|
||||
// cancellation) and get a higher ceiling, normal rooms keep their own value.
|
||||
this.audio.setProfile(hifi);
|
||||
this.settings.updateMicGainUi();
|
||||
|
||||
// Mic permission & AudioContext: request immediately on the user-click gesture
|
||||
// so browsers don't block the microphone permission prompt or audio autoplay policy.
|
||||
await this.audio.ensureContext();
|
||||
s.audioCtx = this.audio.context;
|
||||
this.spatial.orientListener();
|
||||
|
||||
// Attach a listener in case browser autoplay policy keeps AudioContext suspended
|
||||
window.addEventListener('click', this.resumeCtx, { passive: true });
|
||||
window.addEventListener('keydown', this.resumeCtx, { passive: true });
|
||||
|
||||
try {
|
||||
s.rawMicStream = await navigator.mediaDevices.getUserMedia(this.mic.microphoneConstraints({ hifi }));
|
||||
s.micStream = await this.audio.attachInput(s.rawMicStream);
|
||||
void this.mic.populateAudioInputs();
|
||||
} catch {
|
||||
s.micStream = null;
|
||||
toast('No microphone available — joining in listen-only mode', 'warn');
|
||||
}
|
||||
s.muted = false;
|
||||
this.updateMicButton();
|
||||
for (const btn of this.mirror.shareButtons) btn.disabled = false;
|
||||
|
||||
try {
|
||||
s.iceServers = await this.fetchIce();
|
||||
} catch {
|
||||
toast('Could not load ICE config from server — using fallback STUN', 'warn');
|
||||
}
|
||||
|
||||
await this.signaling.openSignaling(room);
|
||||
s.room = room;
|
||||
s.connecting = false;
|
||||
this.playCue('join');
|
||||
if (hifi) toast('Hi-Fi audio on: stereo, 256 kbps, echo cancellation off — headphones recommended', 'ok');
|
||||
this.renderAll();
|
||||
}
|
||||
|
||||
leaveVoice(reason?: string): void {
|
||||
const s = this.state;
|
||||
if (!s.room && !this.signaling.open) return;
|
||||
const wasConnected = Boolean(s.room);
|
||||
if (wasConnected) this.playCue('leave');
|
||||
if (s.sharing) this.screenShare.stop(undefined, true);
|
||||
this.webrtc.destroyAllPeers();
|
||||
this.signaling.close();
|
||||
// Back to the normal-room gain profile. leaveVoice also runs while switching
|
||||
// channels; joinVoice re-selects the target profile straight after, so the
|
||||
// intermediate hop never touches the microphone.
|
||||
this.audio.setProfile(false);
|
||||
this.audio.stop();
|
||||
s.rawMicStream = null;
|
||||
s.micStream = null;
|
||||
// Keep share buttons enabled so clicking one prompts the user to join a channel
|
||||
this.updateMicButton();
|
||||
document.querySelectorAll('.channel.voice').forEach((c) => c.classList.remove('active'));
|
||||
s.room = null;
|
||||
s.myId = null;
|
||||
s.muted = false;
|
||||
s.deafened = false;
|
||||
this.updateDeafenButton();
|
||||
this.renderAll();
|
||||
this.setStatus(reason || 'not connected');
|
||||
void this.fetchRooms();
|
||||
}
|
||||
|
||||
// --- controls -----------------------------------------------------------------
|
||||
|
||||
toggleMic(): void {
|
||||
if (!this.state.micStream) return;
|
||||
this.state.muted = !this.state.muted;
|
||||
this.state.micStream.getAudioTracks().forEach((t) => (t.enabled = !this.state.muted));
|
||||
this.updateMicButton();
|
||||
this.sendState();
|
||||
this.renderTiles();
|
||||
}
|
||||
|
||||
toggleDeafen(): void {
|
||||
if (!this.state.audioCtx) return;
|
||||
this.state.deafened = !this.state.deafened;
|
||||
for (const peer of this.state.peers.values()) {
|
||||
if (peer.gain) peer.gain.gain.value = this.state.deafened ? 0 : peer.vol;
|
||||
}
|
||||
this.updateDeafenButton();
|
||||
this.sendState();
|
||||
this.renderTiles();
|
||||
}
|
||||
|
||||
startScreenShare(): void {
|
||||
void this.screenShare.start();
|
||||
}
|
||||
|
||||
// --- signaling dispatch ---------------------------------------------------------
|
||||
|
||||
handleSignal(msgRaw: unknown): void {
|
||||
if (!msgRaw || typeof msgRaw !== 'object') return;
|
||||
const msg = msgRaw as ServerSignal;
|
||||
const s = this.state;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'rosters': {
|
||||
s.channelRosters = msg.rooms || {};
|
||||
this.renderTiles();
|
||||
this.renderChannelCounts();
|
||||
break;
|
||||
}
|
||||
case 'hello': {
|
||||
s.myId = msg.id;
|
||||
s.room = msg.room;
|
||||
for (const p of msg.peers) {
|
||||
s.peers.set(p.id, this.webrtc.makePeerRecord(p));
|
||||
// deterministic initiator: lower id offers (no glare storm on join)
|
||||
this.webrtc.ensurePC(s.peers.get(p.id)!, (s.myId || '') < p.id);
|
||||
}
|
||||
s.me = chooseOpenParticipantPosition(s.peers.size);
|
||||
this.sendMyPos(true);
|
||||
this.renderAll();
|
||||
this.setStatus(`voice connected — ${msg.peers.length + 1} in ${msg.room}`, 'online');
|
||||
toast(`Joined ${msg.room}`, 'ok');
|
||||
break;
|
||||
}
|
||||
case 'joined': {
|
||||
// If candidates for this peer arrived before its description (orphan
|
||||
// record from the candidate handler), carry the buffer over instead of
|
||||
// dropping it with the old record.
|
||||
const prev = s.peers.get(msg.peer.id);
|
||||
const peer = this.webrtc.makePeerRecord(msg.peer);
|
||||
if (prev?.pendingCandidates.length) peer.pendingCandidates = prev.pendingCandidates;
|
||||
s.peers.set(msg.peer.id, peer);
|
||||
this.playCue('join');
|
||||
this.renderAll();
|
||||
toast(`${msg.peer.name} joined`, 'ok');
|
||||
break;
|
||||
}
|
||||
case 'left': {
|
||||
const peer = s.peers.get(msg.id);
|
||||
if (peer) {
|
||||
this.playCue('leave');
|
||||
this.webrtc.destroyPeer(peer);
|
||||
s.peers.delete(msg.id);
|
||||
this.renderAll();
|
||||
toast(`${peer.name} left`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'pos': {
|
||||
const peer = s.peers.get(msg.from);
|
||||
if (peer) {
|
||||
peer.x = msg.x;
|
||||
peer.y = msg.y;
|
||||
this.updatePanner(peer);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'state': {
|
||||
const peer = s.peers.get(msg.from);
|
||||
if (peer) {
|
||||
peer.state = msg.state;
|
||||
this.renderTiles();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'offer':
|
||||
case 'answer': {
|
||||
const peer = this.webrtc.ensurePC(s.peers.get(msg.from) || this.webrtc.createOrphanPeer(msg.from), false);
|
||||
this.webrtc.onDescription(peer, msg.sdp).catch((e) => {
|
||||
console.error('description failed', e);
|
||||
captureRumIssue('WebRTC session description failed', 'ERROR', {
|
||||
peerId: peer.id,
|
||||
type: msg.type,
|
||||
error: (e as Error)?.message || String(e),
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'candidate': {
|
||||
// A candidate can legitimately arrive before its description is
|
||||
// applied: during a join storm the offer may still be inside
|
||||
// createOffer(), and after a 'reset' the pc is briefly null mid-rebuild.
|
||||
// Dropping those loses srflx/relay candidates the pair may have no
|
||||
// alternative for, so buffer them — onDescription() flushes
|
||||
// peer.pendingCandidates once the remote description lands.
|
||||
const peer = s.peers.get(msg.from) || this.webrtc.createOrphanPeer(msg.from);
|
||||
this.webrtc.onCandidate(peer, msg.candidate).catch((e) => {
|
||||
console.error('candidate failed', e);
|
||||
captureRumIssue('WebRTC ICE candidate failed', 'WARNING', { peerId: peer.id, error: (e as Error)?.message || String(e) });
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'reset': {
|
||||
// The other side's connection to us failed and it's rebuilding from
|
||||
// scratch — tear down our half so a fresh offer lands on a clean
|
||||
// RTCPeerConnection instead of an already-established (and now
|
||||
// mismatched) DTLS transport, and count the failure toward the relay
|
||||
// fallback so both halves force TURN on the retry.
|
||||
// See recreatePeerConnection().
|
||||
const peer = s.peers.get(msg.from);
|
||||
if (peer) this.webrtc.recreatePeerConnection(peer, { announce: false, remote: true });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// --- boot & wiring -----------------------------------------------------------------
|
||||
|
||||
/** Initial render + periodic roster polling. Called once from main.ts. */
|
||||
boot(): void {
|
||||
this.updateMicButton();
|
||||
this.updateDeafenButton();
|
||||
void this.fetchRooms();
|
||||
this.monitor.start();
|
||||
setInterval(() => {
|
||||
// If not connected to websocket, poll for changes across rooms
|
||||
if (!this.signaling.open) void this.fetchRooms();
|
||||
}, 5000);
|
||||
if (!this.state.name) this.welcome.open(true);
|
||||
}
|
||||
|
||||
private wire(): void {
|
||||
const s = this.state;
|
||||
|
||||
for (const btn of this.mirror.micButtons) btn.onclick = () => this.toggleMic();
|
||||
for (const btn of this.mirror.deafenButtons) btn.onclick = () => this.toggleDeafen();
|
||||
for (const btn of this.mirror.shareButtons) btn.onclick = () => this.startScreenShare();
|
||||
document.querySelector<HTMLButtonElement>('#btn-settings')!.onclick = () => this.settings.open();
|
||||
|
||||
// nickname editing
|
||||
if (this.els.nick) {
|
||||
this.els.nick.value = s.name;
|
||||
this.els.nick.addEventListener('change', () => {
|
||||
const v = this.els.nick!.value.trim();
|
||||
if (v) s.name = v;
|
||||
this.els.nick!.value = s.name;
|
||||
localStorage.setItem(LS_NAME, s.name);
|
||||
if (this.els.meAvatar) setAvatar(this.els.meAvatar, s.name, s.myId || 'me');
|
||||
if (this.signaling.open) this.send({ type: 'rename', name: s.name });
|
||||
this.renderTiles();
|
||||
});
|
||||
this.els.nick.addEventListener('blur', () => {
|
||||
this.els.nick!.value = s.name;
|
||||
localStorage.setItem(LS_NAME, s.name);
|
||||
if (this.els.meAvatar) setAvatar(this.els.meAvatar, s.name, s.myId || 'me');
|
||||
});
|
||||
}
|
||||
if (this.els.meAvatar) setAvatar(this.els.meAvatar, s.name, 'me');
|
||||
|
||||
// voice channel joining
|
||||
document.querySelectorAll<HTMLElement>('.channel.voice').forEach((el) => {
|
||||
el.onclick = () => {
|
||||
this.layout.closeSidebar();
|
||||
this.matrix.switchToVoiceView();
|
||||
if (s.room === el.dataset.room) return;
|
||||
this.cancelReconnect();
|
||||
document.querySelectorAll('.channel.voice').forEach((c) => c.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
this.joinVoice(el.dataset.room || 'general').catch((e) => {
|
||||
console.error(e);
|
||||
toast(`Join failed: ${(e as Error).message}`, 'error');
|
||||
this.leaveVoice('join failed');
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
if (this.spatialMap) {
|
||||
document.querySelector('#btn-spatial-fit')?.addEventListener('click', () => this.spatialMap!.fit());
|
||||
}
|
||||
|
||||
// keyboard shortcuts
|
||||
window.addEventListener('keydown', (ev) => {
|
||||
const target = ev.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
|
||||
if (ev.key === 'm') this.toggleMic();
|
||||
if (ev.key === 'd') this.toggleDeafen();
|
||||
if (ev.key === 's') this.startScreenShare();
|
||||
});
|
||||
window.addEventListener('beforeunload', () => {
|
||||
try {
|
||||
this.cancelReconnect();
|
||||
this.leaveVoice();
|
||||
} catch {}
|
||||
});
|
||||
navigator.mediaDevices?.addEventListener('devicechange', () => void this.mic.populateAudioInputs());
|
||||
}
|
||||
}
|
||||
|
||||
// First joiner takes the room's middle anchor; later joiners fan out around
|
||||
// them — the 2nd participant lands straight ahead (0deg), then each next one
|
||||
// alternates sides, widening by 10deg per pair (0, +10, -10, +20, -20, ...).
|
||||
// Chosen once per client at join and broadcast via the signaling 'pos'
|
||||
// message, so every client converges on the same layout.
|
||||
function chooseOpenParticipantPosition(peerCount: number): { x: number; y: number } {
|
||||
const ANCHOR = { x: 50, y: 50 };
|
||||
const RADIUS = 24;
|
||||
const n = peerCount;
|
||||
if (n === 0) return { x: ANCHOR.x, y: ANCHOR.y }; // first joiner sits in the middle
|
||||
const k = n - 1; // fan slot: 0deg, +10, -10, +20, -20...
|
||||
const mag = Math.ceil(k / 2) * 10;
|
||||
const angleDeg = k === 0 ? 0 : k % 2 === 1 ? mag : -mag;
|
||||
const rad = (angleDeg * Math.PI) / 180;
|
||||
return {
|
||||
x: ANCHOR.x + RADIUS * Math.sin(rad),
|
||||
y: ANCHOR.y - RADIUS * Math.cos(rad),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import * as matrixcs from 'matrix-js-sdk';
|
||||
import { LS_MX_CLIENT_ID, LS_MX_SESSION, MX_BASE_URL, MX_SERVER_NAME, SS_MX_PENDING } from '../constants';
|
||||
import { toast } from '../ui/elements';
|
||||
import type { AppElements } from '../ui/elements';
|
||||
import type { VoiceAppContext } from '../context';
|
||||
|
||||
// The OAuth2/OIDC helpers are still marked experimental in matrix-js-sdk and
|
||||
// ship without complete .d.ts — talk to them through a minimal local facade.
|
||||
interface MxOAuth2Context {
|
||||
deviceId?: string;
|
||||
codeVerifier?: string;
|
||||
redirectUri?: string;
|
||||
}
|
||||
interface MxOAuth2 {
|
||||
context: MxOAuth2Context;
|
||||
generateAuthorizationCodeGrantUrl(state: string, prompt?: string): Promise<string>;
|
||||
completeAuthorizationCodeGrant(code: string): Promise<{ access_token: string; refresh_token?: string }>;
|
||||
}
|
||||
interface MxOAuth2Static {
|
||||
new (metadata: unknown, opts: Record<string, unknown>): MxOAuth2;
|
||||
registerClient(metadata: unknown, opts: Record<string, unknown>): Promise<string>;
|
||||
}
|
||||
interface MxPendingLogin {
|
||||
state: string;
|
||||
authMetadata: unknown;
|
||||
clientId: string;
|
||||
deviceId?: string;
|
||||
codeVerifier?: string;
|
||||
redirectUri?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matrix text chat (experimental) — native OAuth2/OIDC auth against the
|
||||
* homeserver's own authorization server (MSC2965/MSC3861), so there's no
|
||||
* separate Matrix password: login redirects to matrix.k6n.net itself.
|
||||
* No E2E crypto — rooms are plaintext, matching this app's POC posture.
|
||||
*/
|
||||
export class MatrixChat {
|
||||
private client: any = null;
|
||||
private roomId: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly ctx: VoiceAppContext,
|
||||
private readonly els: AppElements,
|
||||
private readonly callbacks: { switchToVoiceView(): void; resizeSpatial(): void },
|
||||
) {
|
||||
this.wire();
|
||||
const session = this.readSession();
|
||||
if (session) void this.boot(session);
|
||||
this.handleLoginCallback();
|
||||
}
|
||||
|
||||
get connected(): boolean {
|
||||
return this.roomId !== null;
|
||||
}
|
||||
|
||||
private readSession(): { accessToken: string; refreshToken: string | null; userId: string; deviceId: string } | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_MX_SESSION);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private redirectUri(): string {
|
||||
return location.origin + location.pathname;
|
||||
}
|
||||
|
||||
private async registerClientId(authMetadata: unknown): Promise<string> {
|
||||
const cached = localStorage.getItem(LS_MX_CLIENT_ID);
|
||||
if (cached) return cached;
|
||||
const OAuth2 = (matrixcs as unknown as { OAuth2: MxOAuth2Static }).OAuth2;
|
||||
void OAuth2;
|
||||
const clientId = await (matrixcs as any).OAuth2.registerClient(authMetadata, {
|
||||
client_name: 'gibbz voice',
|
||||
client_uri: this.redirectUri(),
|
||||
redirect_uris: [this.redirectUri()],
|
||||
application_type: 'web',
|
||||
});
|
||||
localStorage.setItem(LS_MX_CLIENT_ID, clientId);
|
||||
return clientId;
|
||||
}
|
||||
|
||||
async startLogin(): Promise<void> {
|
||||
try {
|
||||
const bare = matrixcs.createClient({ baseUrl: MX_BASE_URL });
|
||||
const authMetadata = await bare.getAuthMetadata();
|
||||
const clientId = await this.registerClientId(authMetadata);
|
||||
const OAuth2Ctor = (matrixcs as unknown as { OAuth2: MxOAuth2Static }).OAuth2;
|
||||
const oauth = new OAuth2Ctor(authMetadata, { clientId, redirectUri: this.redirectUri() });
|
||||
const state = crypto.randomUUID();
|
||||
sessionStorage.setItem(
|
||||
SS_MX_PENDING,
|
||||
JSON.stringify({
|
||||
state,
|
||||
authMetadata,
|
||||
clientId,
|
||||
deviceId: oauth.context.deviceId,
|
||||
codeVerifier: oauth.context.codeVerifier,
|
||||
redirectUri: oauth.context.redirectUri,
|
||||
} satisfies MxPendingLogin),
|
||||
);
|
||||
const url = await oauth.generateAuthorizationCodeGrantUrl(state, 'query');
|
||||
window.location.href = url;
|
||||
} catch (err) {
|
||||
console.error('matrix login failed to start', err);
|
||||
toast(`Matrix login failed: ${(err as Error).message || err}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
private async completeLogin(code: string, state: string): Promise<void> {
|
||||
const raw = sessionStorage.getItem(SS_MX_PENDING);
|
||||
sessionStorage.removeItem(SS_MX_PENDING);
|
||||
if (!raw) {
|
||||
toast('Matrix login callback received but no pending login found', 'error');
|
||||
return;
|
||||
}
|
||||
const pending = JSON.parse(raw) as MxPendingLogin;
|
||||
if (pending.state !== state) {
|
||||
toast('Matrix login state mismatch — try again', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const OAuth2Ctor = (matrixcs as unknown as { OAuth2: MxOAuth2Static }).OAuth2;
|
||||
const oauth = new OAuth2Ctor(pending.authMetadata, {
|
||||
clientId: pending.clientId,
|
||||
deviceId: pending.deviceId,
|
||||
codeVerifier: pending.codeVerifier,
|
||||
redirectUri: pending.redirectUri,
|
||||
});
|
||||
const tokens = await oauth.completeAuthorizationCodeGrant(code);
|
||||
await this.adoptTokens(tokens);
|
||||
toast('Matrix connected', 'ok');
|
||||
this.showPanel();
|
||||
} catch (err) {
|
||||
console.error('matrix login completion failed', err);
|
||||
toast(`Matrix login failed: ${(err as Error).message || err}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
private async adoptTokens(tokens: { access_token: string; refresh_token?: string }): Promise<void> {
|
||||
const probe = matrixcs.createClient({ baseUrl: MX_BASE_URL, accessToken: tokens.access_token });
|
||||
const who = await probe.whoami();
|
||||
const session = {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || null,
|
||||
userId: who.user_id,
|
||||
deviceId: who.device_id as string | undefined,
|
||||
};
|
||||
localStorage.setItem(LS_MX_SESSION, JSON.stringify(session));
|
||||
await this.boot(session);
|
||||
}
|
||||
|
||||
private async boot(session: { accessToken: string; refreshToken: string | null; userId: string; deviceId?: string }): Promise<void> {
|
||||
this.client = matrixcs.createClient({
|
||||
baseUrl: MX_BASE_URL,
|
||||
accessToken: session.accessToken,
|
||||
refreshToken: session.refreshToken || undefined,
|
||||
userId: session.userId,
|
||||
deviceId: session.deviceId,
|
||||
fallbackICEServerAllowed: false,
|
||||
});
|
||||
this.client.on(matrixcs.ClientEvent.Sync, (state: string) => {
|
||||
if (state === 'PREPARED') void this.onReady();
|
||||
});
|
||||
this.client.on(matrixcs.RoomEvent.Timeline, (event: any, room: any) => {
|
||||
if (room && room.roomId === this.roomId && event.getType() === 'm.room.message') this.renderTimeline();
|
||||
});
|
||||
await this.client.startClient({ initialSyncLimit: 30 });
|
||||
}
|
||||
|
||||
private async onReady(): Promise<void> {
|
||||
try {
|
||||
this.roomId = await this.joinOrCreateRoom('gibbz-general', 'gibbz general');
|
||||
this.renderTimeline();
|
||||
} catch (err) {
|
||||
console.error('matrix room join/create failed', err);
|
||||
toast(`Could not open #general text channel: ${(err as Error).message || err}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
private async joinOrCreateRoom(aliasLocalPart: string, name: string): Promise<string> {
|
||||
const alias = `#${aliasLocalPart}:${MX_SERVER_NAME}`;
|
||||
try {
|
||||
const res = await this.client.joinRoom(alias);
|
||||
return res.roomId;
|
||||
} catch (err: any) {
|
||||
if (err?.errcode !== 'M_NOT_FOUND') throw err;
|
||||
const created = await this.client.createRoom({
|
||||
room_alias_name: aliasLocalPart,
|
||||
name,
|
||||
visibility: 'public',
|
||||
preset: 'public_chat',
|
||||
});
|
||||
return created.room_id;
|
||||
}
|
||||
}
|
||||
|
||||
private renderTimeline(): void {
|
||||
if (!this.client || !this.roomId || !this.els.mxMessages) return;
|
||||
const room = this.client.getRoom(this.roomId);
|
||||
if (!room) return;
|
||||
const events = room.getLiveTimeline().getEvents().filter((e: any) => e.getType() === 'm.room.message');
|
||||
this.els.mxMessages.innerHTML = '';
|
||||
for (const ev of events) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'mx-msg';
|
||||
const sender = document.createElement('span');
|
||||
sender.className = 'mx-sender';
|
||||
sender.textContent = (ev.sender && ev.sender.name) || ev.getSender();
|
||||
const body = document.createElement('span');
|
||||
body.className = 'mx-body';
|
||||
body.textContent = ev.getContent().body || '';
|
||||
row.append(sender, body);
|
||||
this.els.mxMessages.appendChild(row);
|
||||
}
|
||||
this.els.mxMessages.scrollTop = this.els.mxMessages.scrollHeight;
|
||||
}
|
||||
|
||||
switchToChatView(): void {
|
||||
if (this.ctx.els.voiceView) this.ctx.els.voiceView.classList.add('hidden');
|
||||
if (this.els.mxPanel) this.els.mxPanel.classList.remove('hidden');
|
||||
document.querySelectorAll('.channel.text').forEach((c) => c.classList.add('active'));
|
||||
if (this.els.mxMessages) this.els.mxMessages.scrollTop = this.els.mxMessages.scrollHeight;
|
||||
this.els.mxInput?.focus();
|
||||
}
|
||||
|
||||
switchToVoiceView(): void {
|
||||
if (this.els.mxPanel) this.els.mxPanel.classList.add('hidden');
|
||||
if (this.ctx.els.voiceView) this.ctx.els.voiceView.classList.remove('hidden');
|
||||
document.querySelectorAll('.channel.text').forEach((c) => c.classList.remove('active'));
|
||||
// Trigger Cytoscape redraw/resize because voice-view was unhidden
|
||||
this.callbacks.resizeSpatial();
|
||||
}
|
||||
|
||||
private showPanel(): void {
|
||||
this.switchToChatView();
|
||||
}
|
||||
|
||||
private send(text: string): void {
|
||||
if (!this.client || !this.roomId || !text.trim()) return;
|
||||
this.client.sendTextMessage(this.roomId, text);
|
||||
this.renderTimeline();
|
||||
}
|
||||
|
||||
private wire(): void {
|
||||
document.querySelectorAll<HTMLElement>('.channel.text').forEach((el) => {
|
||||
el.onclick = () => {
|
||||
if (this.roomId) {
|
||||
this.switchToChatView();
|
||||
return;
|
||||
}
|
||||
if (this.client) {
|
||||
toast('Connecting to Matrix…');
|
||||
return;
|
||||
}
|
||||
void this.startLogin();
|
||||
};
|
||||
});
|
||||
document.querySelector('#mx-close')?.addEventListener('click', () => this.switchToVoiceView());
|
||||
document.querySelector('#mx-send')?.addEventListener('click', () => {
|
||||
this.send(this.els.mxInput?.value || '');
|
||||
if (this.els.mxInput) this.els.mxInput.value = '';
|
||||
});
|
||||
this.els.mxInput?.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter' && !ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
document.querySelector<HTMLButtonElement>('#mx-send')?.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Completes an OIDC redirect back to this page, if this is one. */
|
||||
private handleLoginCallback(): void {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const code = params.get('code');
|
||||
const state = params.get('state');
|
||||
if (code && state) void this.completeLogin(code, state);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { RtcIceConfig } from './types';
|
||||
|
||||
export const ROOM_UNITS = 100; // spatial plane is 0..100 x 0..100
|
||||
export const SPATIAL_SCALE = 0.12; // room units -> audio world units
|
||||
export const SPATIAL_PX_SCALE = 6; // room units -> cytoscape viewport pixels
|
||||
// Per-user playback boost ceiling: 400% (+12 dB) over the spatial mix, matching
|
||||
// the headroom the playback limiter in attachSpatial() is designed for.
|
||||
export const MAX_USER_VOLUME = 4;
|
||||
|
||||
// Hi-Fi rooms: stereo Opus at 256 kbps with echo cancellation disabled —
|
||||
// for music/listening channels where fidelity beats echo-proofing. Marked
|
||||
// with a HI-FI badge in the channel list.
|
||||
export const HIFI_ROOMS = new Set(['code', 'gaming']);
|
||||
|
||||
export const FALLBACK_ICE: RtcIceConfig = {
|
||||
iceServers: [{ urls: ['stun:turn.k6n.net:3478', 'stun:stun.l.google.com:19302'] }],
|
||||
};
|
||||
|
||||
export const LS_NAME = 'voicepoc.nick';
|
||||
export const LS_ICE = 'voicepoc.iceOverride';
|
||||
export const LS_AUDIO_INPUT = 'voicepoc.audioInput';
|
||||
export const LS_SCREEN_HINT = 'voicepoc.screenHint';
|
||||
export const LS_USER_VOLUME = 'voicepoc.userVolume';
|
||||
export const LS_SIDEBAR_W = 'voicepoc.sidebarWidth';
|
||||
export const LS_SIDEBAR_COLLAPSED = 'voicepoc.sidebarCollapsed';
|
||||
export const LS_SPATIAL_H = 'voicepoc.spatialHeight';
|
||||
export const LS_SPATIAL_COLLAPSED = 'voicepoc.spatialCollapsed';
|
||||
|
||||
export const ICE_NOTICE_INTERVAL = 15000;
|
||||
export const RESET_MAX_ATTEMPTS = 3;
|
||||
export const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 15000]; // caps at 15s
|
||||
|
||||
export const PEER_COLORS = ['#5865f2', '#23a55a', '#f0b232', '#eb459e', '#3ba55c', '#faa61a', '#e67e22', '#9b59b6'];
|
||||
|
||||
// Matrix text chat (experimental) — native OAuth2/OIDC auth against the
|
||||
// homeserver's own authorization server (MSC2965/MSC3861), so there's no
|
||||
// separate Matrix password: login redirects to matrix.k6n.net itself.
|
||||
// No E2E crypto — rooms are plaintext, matching this app's POC posture.
|
||||
export const MX_BASE_URL = 'https://matrix.k6n.net';
|
||||
export const MX_SERVER_NAME = 'matrix.k6n.net';
|
||||
export const LS_MX_CLIENT_ID = 'voicepoc.mxClientId';
|
||||
export const LS_MX_SESSION = 'voicepoc.mxSession';
|
||||
export const SS_MX_PENDING = 'voicepoc.mxOidcPending';
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { AppState } from './state';
|
||||
import type { AudioManager } from './media/audio-manager';
|
||||
import type { MicrophoneController } from './media/microphone';
|
||||
import type { ScreenShareController } from './media/screen-share';
|
||||
import type { SpatialAudio } from './media/spatial';
|
||||
import type { WebRtcManager } from './webrtc/web-rtc-manager';
|
||||
import type { SignalingClient } from './webrtc/signaling-client';
|
||||
import type { ScreensUi } from './ui/screens-ui';
|
||||
import type { RosterUi } from './ui/roster-ui';
|
||||
import type { AppElements } from './ui/elements';
|
||||
import type { ClientSignal, AudioLevel, IcePathInfo, RetroCue, RtcIceConfig } from './types';
|
||||
import type { PeerSession } from './webrtc/peer-session';
|
||||
|
||||
/**
|
||||
* The surface subsystems are allowed to touch on the application. `VoiceApp`
|
||||
* implements this; the interface keeps the module graph acyclic (types only).
|
||||
*/
|
||||
export interface VoiceAppContext {
|
||||
readonly state: AppState;
|
||||
readonly audio: AudioManager;
|
||||
readonly spatial: SpatialAudio;
|
||||
readonly mic: MicrophoneController;
|
||||
readonly roster: RosterUi;
|
||||
readonly els: AppElements;
|
||||
webrtc: WebRtcManager;
|
||||
signaling: SignalingClient;
|
||||
screens: ScreensUi;
|
||||
screenShare: ScreenShareController;
|
||||
|
||||
/** Typed accessor for the app's DOM element registry. */
|
||||
el(id: 'screens'): HTMLElement | null;
|
||||
|
||||
send(msg: ClientSignal): void;
|
||||
sendState(): void;
|
||||
sendMyPos(force?: boolean): void;
|
||||
toast(msg: string, kind?: string): void;
|
||||
setStatus(text: string, state?: string): void;
|
||||
|
||||
renderAll(): void;
|
||||
renderTiles(): void;
|
||||
renderScreens(): void;
|
||||
renderChannelCounts(): void;
|
||||
renderStageEmpty(): void;
|
||||
|
||||
updatePanner(peer: PeerSession): void;
|
||||
playCue(name: RetroCue): void;
|
||||
notifyIceIssue(peer: PeerSession | null, message: string, extra?: Record<string, unknown>): void;
|
||||
icePathOf(pc: RTCPeerConnection): Promise<IcePathInfo | null>;
|
||||
isMeSpeaking(): boolean;
|
||||
setMeAvatarSpeaking(on: boolean): void;
|
||||
updateMicSignal(level?: AudioLevel): void;
|
||||
|
||||
roomIsHifi(room?: string | null): boolean;
|
||||
|
||||
updateMicButton(): void;
|
||||
updateDeafenButton(): void;
|
||||
updateMicGainUi(): void;
|
||||
|
||||
fetchIce(): Promise<RtcIceConfig>;
|
||||
fetchRooms(): Promise<void>;
|
||||
joinVoice(room: string): Promise<void>;
|
||||
leaveVoice(reason?: string): void;
|
||||
beginReconnect(room: string): void;
|
||||
cancelReconnect(): void;
|
||||
|
||||
handleSignal(msg: unknown): void;
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Gameflow</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
|
||||
@@ -213,7 +212,7 @@
|
||||
|
||||
<div id="toasts"></div>
|
||||
|
||||
<script type="module" src="/app.js"></script>
|
||||
<script type="module" src="/main.ts"></script>
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
@@ -0,0 +1,9 @@
|
||||
import './style.css';
|
||||
|
||||
import { installGlobalErrorHandlers } from './rum';
|
||||
import { VoiceApp } from './app';
|
||||
|
||||
installGlobalErrorHandlers();
|
||||
|
||||
const app = new VoiceApp();
|
||||
app.boot();
|
||||
@@ -1,20 +1,22 @@
|
||||
// Microphone capture chain: input -> gain -> (analyser) -> limiter -> output.
|
||||
//
|
||||
// Microphone gain ceilings. Normal rooms capture with echo cancellation enabled,
|
||||
// which already lifts quiet microphones. Hi-Fi rooms capture raw (no AGC, no echo
|
||||
// cancellation) for fidelity, so quiet mics arrive much lower and need more
|
||||
// make-up gain before they reach the Opus encoder.
|
||||
export const MAX_MIC_GAIN = 4; // 400% (+12 dB) — normal rooms
|
||||
export const MAX_MIC_GAIN = 4; // 400% (+12 dB) — normal rooms
|
||||
export const MAX_MIC_GAIN_HIFI = 8; // 800% (+18 dB) — Hi-Fi rooms, raw capture
|
||||
|
||||
// Gain is stored per profile: dropping back from a Hi-Fi room to a normal one must
|
||||
// not silently truncate a +18 dB setting the user dialled in.
|
||||
const GAIN_KEY = { normal: 'voicepoc.micGain', hifi: 'voicepoc.micGainHifi' };
|
||||
const GAIN_KEY = { normal: 'voicepoc.micGain', hifi: 'voicepoc.micGainHifi' } as const;
|
||||
|
||||
// Peaks above 0 dBFS are hard-clipped when the float graph is converted for the
|
||||
// Opus encoder, which turns a big boost into crunch. The limiter only engages
|
||||
// near full scale, so normal-level speech passes through untouched.
|
||||
const LIMITER = { threshold: -1.5, knee: 0, ratio: 20, attack: 0.002, release: 0.15 };
|
||||
|
||||
function makeLimiter(context) {
|
||||
function makeLimiter(context: AudioContext): DynamicsCompressorNode {
|
||||
const limiter = context.createDynamicsCompressor();
|
||||
limiter.threshold.value = LIMITER.threshold;
|
||||
limiter.knee.value = LIMITER.knee;
|
||||
@@ -24,54 +26,69 @@ function makeLimiter(context) {
|
||||
return limiter;
|
||||
}
|
||||
|
||||
function clampGain(value, max) {
|
||||
function clampGain(value: unknown, max: number): number {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return 1;
|
||||
return Math.max(0, Math.min(max, n));
|
||||
}
|
||||
|
||||
interface MicChain {
|
||||
source: MediaStreamAudioSourceNode | null;
|
||||
gainNode: GainNode | null;
|
||||
limiter: DynamicsCompressorNode | null;
|
||||
analyser: AnalyserNode | null;
|
||||
meterSink: GainNode | null;
|
||||
outputStream: MediaStream | null;
|
||||
}
|
||||
|
||||
export class AudioManager {
|
||||
context: AudioContext | null = null;
|
||||
rawStream: MediaStream | null = null;
|
||||
private chain: MicChain = {
|
||||
source: null,
|
||||
gainNode: null,
|
||||
limiter: null,
|
||||
analyser: null,
|
||||
meterSink: null,
|
||||
outputStream: null,
|
||||
};
|
||||
private meterData = new Float32Array(1024);
|
||||
// The room is unknown until joinVoice() resolves it, so the stricter normal-room
|
||||
// profile is the starting point; joinVoice() calls setProfile() before capture.
|
||||
hifi = false;
|
||||
maxGain = MAX_MIC_GAIN;
|
||||
// Clamped on load too: an out-of-range localStorage value (older build, hand
|
||||
// edit) must not bypass the ceiling of the profile it belongs to.
|
||||
gain: number;
|
||||
|
||||
constructor() {
|
||||
this.context = null;
|
||||
this.rawStream = null;
|
||||
this.outputStream = null;
|
||||
this.source = null;
|
||||
this.gainNode = null;
|
||||
this.limiter = null;
|
||||
this.analyser = null;
|
||||
this.meterSink = null;
|
||||
this.meterData = new Float32Array(1024);
|
||||
// The room is unknown until joinVoice() resolves it, so the stricter normal-room
|
||||
// profile is the starting point; joinVoice() calls setProfile() before capture.
|
||||
this.hifi = false;
|
||||
this.maxGain = MAX_MIC_GAIN;
|
||||
// Clamped on load too: an out-of-range localStorage value (older build, hand
|
||||
// edit) must not bypass the ceiling of the profile it belongs to.
|
||||
this.gain = this.readGain();
|
||||
}
|
||||
|
||||
gainKey() {
|
||||
private gainKey(): string {
|
||||
return this.hifi ? GAIN_KEY.hifi : GAIN_KEY.normal;
|
||||
}
|
||||
|
||||
readGain() {
|
||||
private readGain(): number {
|
||||
const stored = localStorage.getItem(this.gainKey());
|
||||
// No stored value yet: 100%. Users in Hi-Fi rooms raise it themselves — the
|
||||
// ceiling change must not make anyone unexpectedly loud on first join.
|
||||
return stored === null ? 1 : clampGain(stored, this.maxGain);
|
||||
}
|
||||
|
||||
storeGain() {
|
||||
private storeGain(): void {
|
||||
localStorage.setItem(this.gainKey(), String(this.gain));
|
||||
}
|
||||
|
||||
applyGain() {
|
||||
if (this.gainNode) this.gainNode.gain.setTargetAtTime(this.gain, this.context.currentTime, 0.015);
|
||||
private applyGain(): void {
|
||||
if (this.chain.gainNode && this.context) {
|
||||
this.chain.gainNode.gain.setTargetAtTime(this.gain, this.context.currentTime, 0.015);
|
||||
}
|
||||
}
|
||||
|
||||
// Switch between the normal and Hi-Fi gain profiles. Safe to call on every join:
|
||||
// it is a no-op when the profile is unchanged.
|
||||
setProfile(hifi) {
|
||||
setProfile(hifi: boolean): number {
|
||||
const next = Boolean(hifi);
|
||||
if (next === this.hifi) return this.gain;
|
||||
this.storeGain(); // keep what the profile we are leaving was set to
|
||||
@@ -82,54 +99,63 @@ export class AudioManager {
|
||||
return this.gain;
|
||||
}
|
||||
|
||||
async ensureContext() {
|
||||
async ensureContext(): Promise<AudioContext> {
|
||||
if (!this.context) this.context = new AudioContext();
|
||||
await this.context.resume().catch(() => {});
|
||||
return this.context;
|
||||
}
|
||||
|
||||
async attachInput(stream) {
|
||||
await this.ensureContext();
|
||||
get analyser(): AnalyserNode | null {
|
||||
return this.chain.analyser;
|
||||
}
|
||||
|
||||
async attachInput(stream: MediaStream): Promise<MediaStream> {
|
||||
const context = await this.ensureContext();
|
||||
this.detachInput();
|
||||
this.rawStream = stream;
|
||||
this.source = this.context.createMediaStreamSource(stream);
|
||||
this.gainNode = this.context.createGain();
|
||||
this.gainNode.gain.value = this.gain;
|
||||
this.limiter = makeLimiter(this.context);
|
||||
this.analyser = this.context.createAnalyser();
|
||||
this.analyser.fftSize = 1024;
|
||||
this.meterSink = this.context.createGain();
|
||||
this.meterSink.gain.value = 0;
|
||||
const destination = this.context.createMediaStreamDestination();
|
||||
this.source.connect(this.gainNode).connect(this.analyser);
|
||||
const source = context.createMediaStreamSource(stream);
|
||||
const gainNode = context.createGain();
|
||||
gainNode.gain.value = this.gain;
|
||||
const limiter = makeLimiter(context);
|
||||
const analyser = context.createAnalyser();
|
||||
analyser.fftSize = 1024;
|
||||
const meterSink = context.createGain();
|
||||
meterSink.gain.value = 0;
|
||||
const destination = context.createMediaStreamDestination();
|
||||
source.connect(gainNode).connect(analyser);
|
||||
// Keep the analyser alive without sending the microphone to the speakers.
|
||||
this.analyser.connect(this.meterSink).connect(this.context.destination);
|
||||
analyser.connect(meterSink).connect(context.destination);
|
||||
// The analyser taps the signal post-gain but pre-limiter, so the clipping
|
||||
// hint reports how hard the gain stage is pushed, not the limited peak.
|
||||
this.gainNode.connect(this.limiter).connect(destination);
|
||||
this.outputStream = destination.stream;
|
||||
return this.outputStream;
|
||||
gainNode.connect(limiter).connect(destination);
|
||||
this.chain = { source, gainNode, limiter, analyser, meterSink, outputStream: destination.stream };
|
||||
return destination.stream;
|
||||
}
|
||||
|
||||
detachInput() {
|
||||
for (const node of [this.source, this.gainNode, this.analyser, this.limiter, this.meterSink]) {
|
||||
try { node?.disconnect(); } catch {}
|
||||
detachInput(): void {
|
||||
for (const node of [this.chain.source, this.chain.gainNode, this.chain.analyser, this.chain.limiter, this.chain.meterSink]) {
|
||||
try {
|
||||
node?.disconnect();
|
||||
} catch {
|
||||
/* already detached */
|
||||
}
|
||||
}
|
||||
this.outputStream?.getTracks().forEach((track) => track.stop());
|
||||
this.chain.outputStream?.getTracks().forEach((track) => track.stop());
|
||||
this.rawStream?.getTracks().forEach((track) => track.stop());
|
||||
this.source = this.gainNode = this.analyser = this.limiter = this.meterSink = null;
|
||||
this.outputStream = this.rawStream = null;
|
||||
this.chain = { source: null, gainNode: null, limiter: null, analyser: null, meterSink: null, outputStream: null };
|
||||
this.rawStream = null;
|
||||
}
|
||||
|
||||
setGain(value) {
|
||||
setGain(value: number | string): void {
|
||||
this.gain = clampGain(value, this.maxGain);
|
||||
this.storeGain();
|
||||
this.applyGain();
|
||||
}
|
||||
|
||||
getLevel() {
|
||||
if (!this.analyser) return { rms: 0, peak: 0, clipping: false };
|
||||
this.analyser.getFloatTimeDomainData(this.meterData);
|
||||
getLevel(): { rms: number; peak: number; clipping: boolean } {
|
||||
const analyser = this.chain.analyser;
|
||||
if (!analyser) return { rms: 0, peak: 0, clipping: false };
|
||||
analyser.getFloatTimeDomainData(this.meterData);
|
||||
let sum = 0;
|
||||
let peak = 0;
|
||||
for (const sample of this.meterData) {
|
||||
@@ -141,7 +167,7 @@ export class AudioManager {
|
||||
return { rms, peak, clipping: peak >= 0.98 };
|
||||
}
|
||||
|
||||
stop() {
|
||||
stop(): void {
|
||||
this.detachInput();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { LS_AUDIO_INPUT } from '../constants';
|
||||
import { captureRumIssue } from '../rum';
|
||||
import { toast } from '../ui/elements';
|
||||
import type { VoiceAppContext } from '../context';
|
||||
|
||||
export interface MicConstraintsOptions {
|
||||
deviceId?: string | null;
|
||||
hifi?: boolean;
|
||||
}
|
||||
|
||||
/** Microphone capture: constraints, device selection and live input switching. */
|
||||
export class MicrophoneController {
|
||||
constructor(private readonly ctx: VoiceAppContext) {}
|
||||
|
||||
private get state() {
|
||||
return this.ctx.state;
|
||||
}
|
||||
|
||||
microphoneConstraints(opts: MicConstraintsOptions = {}): MediaStreamConstraints {
|
||||
const deviceId = opts.deviceId !== undefined ? opts.deviceId : localStorage.getItem(LS_AUDIO_INPUT);
|
||||
const hifi = opts.hifi ?? this.ctx.roomIsHifi();
|
||||
const audio: MediaTrackConstraints = {
|
||||
echoCancellation: !hifi, // hi-fi rooms: raw capture — speakers may feed back, use headphones
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false,
|
||||
channelCount: hifi ? 2 : 1, // ideal, not exact — mono-only mics downgrade gracefully
|
||||
sampleRate: 48000,
|
||||
sampleSize: 16,
|
||||
};
|
||||
if (deviceId) audio.deviceId = { exact: deviceId };
|
||||
return { audio };
|
||||
}
|
||||
|
||||
async populateAudioInputs(): Promise<void> {
|
||||
const select = document.querySelector<HTMLSelectElement>('#audio-input');
|
||||
if (!select || !navigator.mediaDevices?.enumerateDevices) return;
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
const inputs = devices.filter((device) => device.kind === 'audioinput');
|
||||
const selected = localStorage.getItem(LS_AUDIO_INPUT) || '';
|
||||
select.replaceChildren(new Option('System default', ''));
|
||||
inputs.forEach((device, index) => {
|
||||
select.add(new Option(device.label || `Microphone ${index + 1}`, device.deviceId));
|
||||
});
|
||||
select.value = inputs.some((device) => device.deviceId === selected) ? selected : '';
|
||||
if (selected && !select.value) localStorage.removeItem(LS_AUDIO_INPUT);
|
||||
} catch (err) {
|
||||
console.warn('enumerating audio inputs failed', err);
|
||||
captureRumIssue('Could not enumerate audio inputs', 'WARNING', { error: (err as Error)?.message || String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-captures the mic on another input device, hot-swapping the track into
|
||||
* every live peer sender without renegotiation.
|
||||
*/
|
||||
async switchAudioInput(deviceId: string): Promise<void> {
|
||||
const previousDeviceId = localStorage.getItem(LS_AUDIO_INPUT) || '';
|
||||
if (deviceId) localStorage.setItem(LS_AUDIO_INPUT, deviceId);
|
||||
else localStorage.removeItem(LS_AUDIO_INPUT);
|
||||
if (!this.state.room || !this.state.micStream) return;
|
||||
|
||||
let nextStream: MediaStream;
|
||||
try {
|
||||
nextStream = await navigator.mediaDevices.getUserMedia(this.microphoneConstraints({ deviceId }));
|
||||
} catch (err) {
|
||||
if (previousDeviceId) localStorage.setItem(LS_AUDIO_INPUT, previousDeviceId);
|
||||
else localStorage.removeItem(LS_AUDIO_INPUT);
|
||||
const select = document.querySelector<HTMLSelectElement>('#audio-input');
|
||||
if (select) select.value = previousDeviceId;
|
||||
toast(`Could not open microphone: ${(err as Error)?.message || err}`, 'error');
|
||||
captureRumIssue('Microphone switch failed', 'WARNING', { error: (err as Error)?.message || String(err) });
|
||||
return;
|
||||
}
|
||||
|
||||
const nextOutput = await this.ctx.audio.attachInput(nextStream);
|
||||
const nextTrack = nextOutput.getAudioTracks()[0];
|
||||
for (const peer of this.state.peers.values()) {
|
||||
if (!peer.pc) continue;
|
||||
for (const sender of peer.pc.getSenders()) {
|
||||
if (sender.track?.kind === 'audio' && nextTrack) await sender.replaceTrack(nextTrack);
|
||||
}
|
||||
}
|
||||
this.state.rawMicStream = nextStream;
|
||||
this.state.micStream = nextOutput;
|
||||
this.ctx.updateMicButton();
|
||||
toast('Microphone changed', 'ok');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { LS_SCREEN_HINT } from '../constants';
|
||||
import { captureRumIssue } from '../rum';
|
||||
import { toast } from '../ui/elements';
|
||||
import type { AppState } from '../state';
|
||||
import type { PeerState } from '../types';
|
||||
import type { VoiceAppContext } from '../context';
|
||||
|
||||
/** Local screen capture: getDisplayMedia, track lifecycle and sender wiring. */
|
||||
export class ScreenShareController {
|
||||
/** Wired by the app to the screens UI for the desktop auto-pop-out. */
|
||||
popout: ((share: { key: string; name: string; stream: MediaStream; local: boolean }) => void) | null = null;
|
||||
|
||||
constructor(private readonly ctx: VoiceAppContext) {}
|
||||
|
||||
private get state(): AppState {
|
||||
return this.ctx.state;
|
||||
}
|
||||
|
||||
private peerState(): PeerState {
|
||||
const s = this.state;
|
||||
return { muted: s.muted, deafened: s.deafened, sharing: s.sharing };
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (!this.state.room) {
|
||||
toast('Join a voice channel first to share your screen', 'warn');
|
||||
return;
|
||||
}
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
|
||||
toast('Screen sharing is not supported in this browser or context', 'error');
|
||||
return;
|
||||
}
|
||||
let stream: MediaStream;
|
||||
try {
|
||||
// Try requesting display media with audio first
|
||||
stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: { frameRate: 30 },
|
||||
audio: true,
|
||||
});
|
||||
} catch (err) {
|
||||
const name = (err as DOMException).name;
|
||||
// If user cancelled / denied, don't toast as error unless it's not NotAllowedError/AbortError
|
||||
if (name === 'NotAllowedError' || name === 'AbortError') return;
|
||||
// Try fallback without audio constraint if audio failed
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: { frameRate: 30 },
|
||||
});
|
||||
} catch (fallbackErr) {
|
||||
const fallbackName = (fallbackErr as DOMException).name;
|
||||
if (fallbackName === 'NotAllowedError' || fallbackName === 'AbortError') return;
|
||||
console.error('Failed to get display media:', fallbackErr);
|
||||
captureRumIssue('WebRTC display media capture failed', 'ERROR', {
|
||||
error: (fallbackErr as Error)?.message || String(fallbackErr),
|
||||
});
|
||||
toast(`Could not share screen: ${(fallbackErr as Error)?.message || fallbackErr}`, 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.state.localScreenStreams.set(stream.id, stream);
|
||||
this.state.sharing = true;
|
||||
this.ctx.playCue('share');
|
||||
this.ctx.send({ type: 'state', state: this.peerState() });
|
||||
|
||||
for (const peer of this.state.peers.values()) this.ctx.webrtc.addScreenToPeer(peer);
|
||||
const videoTrack = stream.getVideoTracks()[0];
|
||||
if (videoTrack) {
|
||||
const screenHint = localStorage.getItem(LS_SCREEN_HINT) || '';
|
||||
if ('contentHint' in videoTrack) videoTrack.contentHint = screenHint;
|
||||
videoTrack.addEventListener('ended', () => this.stop(stream));
|
||||
}
|
||||
this.ctx.renderScreens();
|
||||
// Open a desktop preview automatically; mobile keeps the in-page preview.
|
||||
if (window.matchMedia('(min-width: 700px)').matches && this.popout) {
|
||||
this.popout({ key: `me:${stream.id}`, name: `${this.state.name} (you)`, stream, local: true });
|
||||
}
|
||||
this.ctx.renderTiles();
|
||||
toast('You are sharing your screen', 'ok');
|
||||
}
|
||||
|
||||
stop(stream?: MediaStream, silent = false): void {
|
||||
const streams = stream ? [stream] : [...this.state.localScreenStreams.values()];
|
||||
if (!streams.length) return;
|
||||
this.ctx.webrtc.removeScreenTracks(streams);
|
||||
for (const localStream of streams) {
|
||||
if (this.state.localScreenStreams.get(localStream.id) !== localStream) continue;
|
||||
localStream.getTracks().forEach((t) => t.stop());
|
||||
this.state.localScreenStreams.delete(localStream.id);
|
||||
}
|
||||
this.state.sharing = this.state.localScreenStreams.size > 0;
|
||||
if (!this.state.sharing) this.ctx.webrtc.clearScreenSenders();
|
||||
this.ctx.send({ type: 'state', state: this.peerState() });
|
||||
this.ctx.renderScreens();
|
||||
this.ctx.renderTiles();
|
||||
if (!silent) toast(this.state.sharing ? 'Screen share stopped' : 'Screen sharing stopped');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { AppState } from '../state';
|
||||
import type { AudioManager } from './audio-manager';
|
||||
import type { PeerSession } from '../webrtc/peer-session';
|
||||
import type { RetroCue } from '../types';
|
||||
import { SPATIAL_SCALE } from '../constants';
|
||||
|
||||
/**
|
||||
* Per-peer spatial playback: remote mic -> PannerNode (HRTF) -> analyser ->
|
||||
* per-user gain -> limiter -> destination, plus the retro join/leave cues.
|
||||
*/
|
||||
export class SpatialAudio {
|
||||
private meterBuf = new Uint8Array(256);
|
||||
|
||||
constructor(
|
||||
private readonly state: AppState,
|
||||
private readonly audio: AudioManager,
|
||||
private readonly onCue?: (ctx: AudioContext, cue: RetroCue) => void,
|
||||
) {}
|
||||
|
||||
attach(peer: PeerSession, stream: MediaStream): void {
|
||||
const ctx = this.state.audioCtx;
|
||||
if (!ctx || peer.panner) return;
|
||||
const src = ctx.createMediaStreamSource(stream);
|
||||
const panner = ctx.createPanner();
|
||||
panner.panningModel = 'HRTF';
|
||||
panner.distanceModel = 'inverse';
|
||||
panner.refDistance = 4;
|
||||
panner.rolloffFactor = 1.4;
|
||||
panner.maxDistance = 200;
|
||||
const gain = ctx.createGain();
|
||||
gain.gain.value = this.state.deafened ? 0 : peer.vol;
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
const limiter = makePlaybackLimiter(ctx);
|
||||
// The analyser taps the signal before the per-user gain so the "speaking"
|
||||
// highlight follows the peer's real level: at 400% it would otherwise be on
|
||||
// permanently, and at 0% permanently dark.
|
||||
src.connect(panner).connect(analyser).connect(gain).connect(limiter).connect(ctx.destination);
|
||||
peer.panner = panner;
|
||||
peer.gain = gain;
|
||||
peer.analyser = analyser;
|
||||
peer.limiter = limiter;
|
||||
this.updatePanner(peer);
|
||||
}
|
||||
|
||||
detach(peer: PeerSession): void {
|
||||
if (peer.gain) {
|
||||
try {
|
||||
peer.gain.disconnect();
|
||||
} catch {}
|
||||
}
|
||||
if (peer.limiter) {
|
||||
try {
|
||||
peer.limiter.disconnect();
|
||||
} catch {}
|
||||
}
|
||||
if (peer.panner) {
|
||||
try {
|
||||
peer.panner.disconnect();
|
||||
} catch {}
|
||||
}
|
||||
peer.panner = null;
|
||||
peer.gain = null;
|
||||
peer.analyser = null;
|
||||
peer.limiter = null;
|
||||
}
|
||||
|
||||
updatePanner(peer: PeerSession): void {
|
||||
if (!peer.panner) return;
|
||||
const x = (peer.x - this.state.me.x) * SPATIAL_SCALE;
|
||||
const z = (peer.y - this.state.me.y) * SPATIAL_SCALE; // screen-down = behind listener
|
||||
const p = peer.panner;
|
||||
if (p.positionX) {
|
||||
p.positionX.value = x;
|
||||
p.positionY.value = 0;
|
||||
p.positionZ.value = z;
|
||||
} else {
|
||||
// old Safari
|
||||
p.setPosition(x, 0, z);
|
||||
}
|
||||
}
|
||||
|
||||
orientListener(): void {
|
||||
const l = this.state.audioCtx?.listener;
|
||||
if (!l) return;
|
||||
if (l.forwardX) {
|
||||
l.forwardX.value = 0;
|
||||
l.forwardY.value = 0;
|
||||
l.forwardZ.value = -1;
|
||||
l.upX.value = 0;
|
||||
l.upY.value = 1;
|
||||
l.upZ.value = 0;
|
||||
} else {
|
||||
(l as AudioListener & { setOrientation: (x: number, y: number, z: number, ux: number, uy: number, uz: number) => void }).setOrientation(0, 0, -1, 0, 1, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/** RMS of a peer's post-panner analyser — drives the speaking highlight. */
|
||||
peerRms(peer: PeerSession): number {
|
||||
if (!peer.analyser) return 0;
|
||||
peer.analyser.getByteTimeDomainData(this.meterBuf);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < this.meterBuf.length; i++) {
|
||||
const v = (this.meterBuf[i] - 128) / 128;
|
||||
sum += v * v;
|
||||
}
|
||||
return Math.sqrt(sum / this.meterBuf.length);
|
||||
}
|
||||
|
||||
localRms(): { rms: number; peak: number; clipping: boolean } {
|
||||
return this.audio.getLevel();
|
||||
}
|
||||
|
||||
playCue(name: RetroCue): void {
|
||||
const ctx = this.state.audioCtx;
|
||||
if (!ctx) return;
|
||||
if (this.onCue) {
|
||||
this.onCue(ctx, name);
|
||||
return;
|
||||
}
|
||||
playRetroCue(ctx, name);
|
||||
}
|
||||
}
|
||||
|
||||
// Playback headroom: the destination hard-clips anything above 0 dBFS, so a
|
||||
// 400% per-user boost needs a limiter to stay loud instead of crunching.
|
||||
function makePlaybackLimiter(ctx: AudioContext): DynamicsCompressorNode {
|
||||
const limiter = ctx.createDynamicsCompressor();
|
||||
limiter.threshold.value = -1.5;
|
||||
limiter.knee.value = 0;
|
||||
limiter.ratio.value = 20;
|
||||
limiter.attack.value = 0.002;
|
||||
limiter.release.value = 0.15;
|
||||
return limiter;
|
||||
}
|
||||
|
||||
function playRetroCue(ctx: AudioContext, name: RetroCue): void {
|
||||
ctx.resume().catch(() => {});
|
||||
|
||||
const cues: Record<RetroCue, { frequency: number; offset: number; duration: number; type: OscillatorType }[]> = {
|
||||
join: [
|
||||
{ frequency: 659, offset: 0, duration: 0.09, type: 'square' },
|
||||
{ frequency: 988, offset: 0.08, duration: 0.16, type: 'square' },
|
||||
],
|
||||
leave: [
|
||||
{ frequency: 523, offset: 0, duration: 0.11, type: 'triangle' },
|
||||
{ frequency: 392, offset: 0.09, duration: 0.18, type: 'triangle' },
|
||||
],
|
||||
share: [
|
||||
{ frequency: 523, offset: 0, duration: 0.08, type: 'square' },
|
||||
{ frequency: 659, offset: 0.07, duration: 0.08, type: 'square' },
|
||||
{ frequency: 784, offset: 0.14, duration: 0.08, type: 'square' },
|
||||
{ frequency: 1047, offset: 0.21, duration: 0.22, type: 'triangle' },
|
||||
],
|
||||
};
|
||||
const cue = cues[name];
|
||||
|
||||
const start = ctx.currentTime;
|
||||
for (const note of cue) {
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
const noteStart = start + note.offset;
|
||||
oscillator.type = note.type;
|
||||
oscillator.frequency.setValueAtTime(note.frequency, noteStart);
|
||||
gain.gain.setValueAtTime(0.0001, noteStart);
|
||||
gain.gain.exponentialRampToValueAtTime(0.055, noteStart + 0.008);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, noteStart + note.duration);
|
||||
oscillator.connect(gain).connect(ctx.destination);
|
||||
oscillator.start(noteStart);
|
||||
oscillator.stop(noteStart + note.duration + 0.02);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// RUM issue capture: forwards client-side errors into the OpenTelemetry RUM
|
||||
// pipeline (window.OtelRUM is loaded from otel.k6n.net in index.html).
|
||||
|
||||
export function captureRumIssue(
|
||||
message: string,
|
||||
severity = 'ERROR',
|
||||
extra: Record<string, unknown> = {},
|
||||
): void {
|
||||
const rum = (globalThis as Record<string, any>).OtelRUM?.last;
|
||||
if (!rum || typeof rum.captureMessage !== 'function') return;
|
||||
try {
|
||||
rum.captureMessage(message, severity, extra);
|
||||
} catch (err) {
|
||||
console.warn('RUM capture failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
export function installGlobalErrorHandlers(): void {
|
||||
window.addEventListener('error', (event) => {
|
||||
captureRumIssue(event.message || 'Unhandled browser error', 'ERROR', {
|
||||
source: event.filename,
|
||||
line: event.lineno,
|
||||
column: event.colno,
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
const reason = (event as PromiseRejectionEvent).reason;
|
||||
captureRumIssue(`Unhandled promise rejection: ${reason?.message || reason || 'unknown error'}`, 'ERROR', {
|
||||
name: reason?.name,
|
||||
stack: reason?.stack,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Trace headers for fetches so server spans link to the browser trace. */
|
||||
export function rumTraceHeaders(): Record<string, string> {
|
||||
const rum = (window as Record<string, any>).rum;
|
||||
if (rum && typeof rum.traceHeaders === 'function') return rum.traceHeaders() || {};
|
||||
return {};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { PeerSnapshot, PeerState, RtcIceConfig } from './types';
|
||||
import { LS_NAME } from './constants';
|
||||
import type { PeerSession } from './webrtc/peer-session';
|
||||
|
||||
/**
|
||||
* Shared mutable voice state — the typed equivalent of the old single `S`
|
||||
* object. Subsystems own their own internals; everything cross-cutting that
|
||||
* multiple classes read lives here.
|
||||
*/
|
||||
export class AppState {
|
||||
myId: string | null = null;
|
||||
room: string | null = null;
|
||||
name: string = localStorage.getItem(LS_NAME) || '';
|
||||
peers = new Map<string, PeerSession>(); // id -> peer session
|
||||
micStream: MediaStream | null = null;
|
||||
rawMicStream: MediaStream | null = null;
|
||||
audioCtx: AudioContext | null = null;
|
||||
me = { x: 50, y: 50 };
|
||||
muted = false;
|
||||
deafened = false;
|
||||
iceServers: RtcIceConfig | null = null; // resolved on join
|
||||
sharing = false;
|
||||
connecting = false;
|
||||
channelRosters: Record<string, PeerSnapshot[]> = {};
|
||||
localScreenStreams = new Map<string, MediaStream>(); // streamId -> local MediaStream
|
||||
|
||||
get wsOpen(): boolean {
|
||||
return this.signalingOpen;
|
||||
}
|
||||
signalingOpen = false;
|
||||
|
||||
snapshotPeer(p: PeerSnapshot): PeerSnapshot {
|
||||
return p;
|
||||
}
|
||||
|
||||
defaultPeerState(): PeerState {
|
||||
return { muted: false, deafened: false, sharing: false };
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,72 @@
|
||||
export interface PeerState {
|
||||
muted: boolean;
|
||||
deafened: boolean;
|
||||
sharing: boolean;
|
||||
}
|
||||
|
||||
export interface PeerSnapshot {
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
state: PeerState;
|
||||
}
|
||||
|
||||
export interface RtcIceServerConfig {
|
||||
urls: string | string[];
|
||||
username?: string;
|
||||
credential?: string;
|
||||
}
|
||||
|
||||
export interface RtcIceConfig {
|
||||
iceServers: RtcIceServerConfig[];
|
||||
iceTransportPolicy?: RTCIceTransportPolicy;
|
||||
}
|
||||
|
||||
export interface IcePathInfo {
|
||||
localType?: string;
|
||||
remoteType?: string;
|
||||
protocol?: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
// --- signaling messages -----------------------------------------------------
|
||||
|
||||
export type ClientSignal =
|
||||
| { type: 'ping' }
|
||||
| { type: 'pos'; x: number; y: number }
|
||||
| { type: 'state'; state: PeerState }
|
||||
| { type: 'offer'; to: string; sdp: RTCSessionDescriptionInit }
|
||||
| { type: 'answer'; to: string; sdp: RTCSessionDescriptionInit }
|
||||
| { type: 'candidate'; to: string; candidate: RTCIceCandidateInit }
|
||||
| { type: 'reset'; to: string }
|
||||
| { type: 'rename'; name: string };
|
||||
|
||||
export type ServerSignal =
|
||||
| { type: 'hello'; id: string; room: string; peers: PeerSnapshot[] }
|
||||
| { type: 'joined'; peer: PeerSnapshot }
|
||||
| { type: 'left'; id: string }
|
||||
| { type: 'pos'; from: string; x: number; y: number }
|
||||
| { type: 'state'; from: string; state: PeerState }
|
||||
| { type: 'offer'; from: string; sdp: RTCSessionDescriptionInit }
|
||||
| { type: 'answer'; from: string; sdp: RTCSessionDescriptionInit }
|
||||
| { type: 'candidate'; from: string; candidate: RTCIceCandidateInit }
|
||||
| { type: 'reset'; from: string }
|
||||
| { type: 'rosters'; rooms: Record<string, PeerSnapshot[]> }
|
||||
| { type: 'pong' };
|
||||
|
||||
export type RetroCue = 'join' | 'leave' | 'share';
|
||||
|
||||
export interface AudioLevel {
|
||||
rms: number;
|
||||
peak: number;
|
||||
clipping: boolean;
|
||||
}
|
||||
|
||||
/** A screen share shown as a tile: ours (local) or a peer's. */
|
||||
export interface ShareInfo {
|
||||
key: string;
|
||||
name: string;
|
||||
stream: MediaStream;
|
||||
local?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Shared DOM element references + tiny view helpers.
|
||||
|
||||
export function $(sel: string): HTMLElement | null {
|
||||
return document.querySelector(sel);
|
||||
}
|
||||
|
||||
export interface AppElements {
|
||||
voiceView: HTMLElement | null;
|
||||
stage: HTMLElement | null;
|
||||
screens: HTMLElement | null;
|
||||
spatial: HTMLElement | null;
|
||||
status: HTMLElement | null;
|
||||
statusDot: HTMLElement | null;
|
||||
statusRow: HTMLElement | null;
|
||||
nick: HTMLInputElement | null;
|
||||
meAvatar: HTMLElement | null;
|
||||
mic: HTMLButtonElement | null;
|
||||
deafen: HTMLButtonElement | null;
|
||||
reconnectBanner: HTMLElement | null;
|
||||
reconnectText: HTMLElement | null;
|
||||
mxPanel: HTMLElement | null;
|
||||
mxMessages: HTMLElement | null;
|
||||
mxInput: HTMLTextAreaElement | null;
|
||||
welcomeBackdrop: HTMLElement | null;
|
||||
welcomeNick: HTMLInputElement | null;
|
||||
welcomeNickError: HTMLElement | null;
|
||||
sidebar: HTMLElement | null;
|
||||
sidebarBackdrop: HTMLElement | null;
|
||||
btnMenu: HTMLElement | null;
|
||||
}
|
||||
|
||||
export function collectElements(): AppElements {
|
||||
return {
|
||||
voiceView: $('#voice-view'),
|
||||
stage: $('#stage'),
|
||||
screens: $('#screens'),
|
||||
spatial: $('#spatial'),
|
||||
status: $('#voice-status'),
|
||||
statusDot: $('#status-dot'),
|
||||
statusRow: $('#status-row'),
|
||||
nick: $('#nick') as HTMLInputElement | null,
|
||||
meAvatar: $('#me-avatar'),
|
||||
mic: $('#btn-mic') as HTMLButtonElement | null,
|
||||
deafen: $('#btn-deafen') as HTMLButtonElement | null,
|
||||
reconnectBanner: $('#reconnect-banner'),
|
||||
reconnectText: $('#reconnect-text'),
|
||||
mxPanel: $('#mx-panel'),
|
||||
mxMessages: $('#mx-messages'),
|
||||
mxInput: $('#mx-input') as HTMLTextAreaElement | null,
|
||||
welcomeBackdrop: $('#welcome-backdrop'),
|
||||
welcomeNick: $('#welcome-nick') as HTMLInputElement | null,
|
||||
welcomeNickError: $('#welcome-nick-error'),
|
||||
sidebar: $('#sidebar'),
|
||||
sidebarBackdrop: $('#sidebar-backdrop'),
|
||||
btnMenu: $('#btn-menu'),
|
||||
};
|
||||
}
|
||||
|
||||
// buttons that appear twice (sidebar + mobile bottom bar) — keep both in sync
|
||||
export function collectMirrorButtons() {
|
||||
return {
|
||||
micButtons: [...document.querySelectorAll<HTMLButtonElement>('#btn-mic, .mirror-mic')],
|
||||
deafenButtons: [...document.querySelectorAll<HTMLButtonElement>('#btn-deafen, .mirror-deafen')],
|
||||
shareButtons: [...document.querySelectorAll<HTMLButtonElement>('#btn-share, .mirror-share')],
|
||||
statusTextEls: [...document.querySelectorAll<HTMLElement>('#voice-status, #mobile-status-text')],
|
||||
statusDotEls: [...document.querySelectorAll<HTMLElement>('#status-dot, #mobile-status-dot')],
|
||||
};
|
||||
}
|
||||
|
||||
export function mIcon(name: string, extraClass = ''): HTMLSpanElement {
|
||||
const span = document.createElement('span');
|
||||
span.className = `material-symbols-outlined${extraClass ? ` ${extraClass}` : ''}`;
|
||||
span.textContent = name;
|
||||
return span;
|
||||
}
|
||||
|
||||
export function toast(msg: string, kind = ''): void {
|
||||
const t = document.createElement('div');
|
||||
t.className = `toast ${kind}`;
|
||||
t.textContent = msg;
|
||||
$('#toasts')?.appendChild(t);
|
||||
setTimeout(() => t.remove(), 5000);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { LS_SIDEBAR_COLLAPSED, LS_SIDEBAR_W, LS_SPATIAL_COLLAPSED, LS_SPATIAL_H } from '../constants';
|
||||
import type { AppElements } from './elements';
|
||||
|
||||
const SIDEBAR_MIN = 180;
|
||||
const SIDEBAR_MAX = 420;
|
||||
const SPATIAL_MIN = 140;
|
||||
const SPATIAL_MAX_RATIO = 0.75;
|
||||
|
||||
export interface LayoutCallbacks {
|
||||
onSpatialResize(): void;
|
||||
onSpatialExpand(): void;
|
||||
applyGridLayout(): void;
|
||||
closeSidebar(): void;
|
||||
}
|
||||
|
||||
const isDesktop = (): boolean => window.matchMedia('(min-width: 861px)').matches;
|
||||
|
||||
interface DragResizeOptions {
|
||||
handle: HTMLElement | null;
|
||||
axis: 'x' | 'y';
|
||||
onMove(dx: number): void;
|
||||
}
|
||||
|
||||
function dragResize({ handle, axis, onMove }: DragResizeOptions): void {
|
||||
if (!handle) return;
|
||||
handle.addEventListener('mousedown', (e) => {
|
||||
if (!isDesktop()) return;
|
||||
e.preventDefault();
|
||||
handle.classList.add('dragging');
|
||||
const start = axis === 'x' ? (e as MouseEvent).clientX : (e as MouseEvent).clientY;
|
||||
const onMouseMove = (ev: Event) =>
|
||||
onMove((axis === 'x' ? (ev as MouseEvent).clientX : (ev as MouseEvent).clientY) - start);
|
||||
const onMouseUp = () => {
|
||||
handle.classList.remove('dragging');
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop panel resizing + sidebar/spatial collapse + the mobile channel drawer.
|
||||
* On mobile the sidebar becomes an off-canvas panel below the #mobile-topbar
|
||||
* breakpoint (see style.css); the drawer calls are no-ops on desktop since the
|
||||
* sidebar is never given the "open" class there.
|
||||
*/
|
||||
export class LayoutManager {
|
||||
private sidebarStartW = 0;
|
||||
private spatialStartH = 0;
|
||||
|
||||
constructor(
|
||||
private readonly els: AppElements,
|
||||
private readonly cb: LayoutCallbacks,
|
||||
) {
|
||||
this.wireResizers();
|
||||
this.wireCollapse();
|
||||
this.wireDrawer();
|
||||
|
||||
// dropping below the desktop breakpoint: let mobile's own CSS take over sizing
|
||||
window.addEventListener('resize', () => {
|
||||
if (isDesktop()) return;
|
||||
if (this.els.sidebar) this.els.sidebar.style.width = '';
|
||||
document.querySelector<HTMLElement>('#spatial-wrap')?.style.removeProperty('height');
|
||||
});
|
||||
}
|
||||
|
||||
closeSidebar(): void {
|
||||
this.els.sidebar?.classList.remove('open');
|
||||
this.els.sidebarBackdrop?.classList.remove('show');
|
||||
}
|
||||
|
||||
private openSidebar(): void {
|
||||
this.els.sidebar?.classList.add('open');
|
||||
this.els.sidebarBackdrop?.classList.add('show');
|
||||
}
|
||||
|
||||
private wireDrawer(): void {
|
||||
this.els.btnMenu?.addEventListener('click', () =>
|
||||
this.els.sidebar?.classList.contains('open') ? this.closeSidebar() : this.openSidebar(),
|
||||
);
|
||||
this.els.sidebarBackdrop?.addEventListener('click', () => this.closeSidebar());
|
||||
document.querySelectorAll('.channel.text').forEach((el) => el.addEventListener('click', () => this.closeSidebar()));
|
||||
}
|
||||
|
||||
private wireResizers(): void {
|
||||
// sidebar width
|
||||
const sidebarResizer = document.querySelector<HTMLElement>('#sidebar-resizer');
|
||||
const savedSidebarW = parseInt(localStorage.getItem(LS_SIDEBAR_W) || '', 10);
|
||||
if (isDesktop() && savedSidebarW && this.els.sidebar) this.els.sidebar.style.width = `${savedSidebarW}px`;
|
||||
dragResize({
|
||||
handle: sidebarResizer,
|
||||
axis: 'x',
|
||||
onMove: (dx) => {
|
||||
if (!this.sidebarStartW && this.els.sidebar) {
|
||||
this.sidebarStartW = this.els.sidebar.getBoundingClientRect().width;
|
||||
}
|
||||
const w = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, this.sidebarStartW + dx));
|
||||
if (this.els.sidebar) this.els.sidebar.style.width = `${w}px`;
|
||||
localStorage.setItem(LS_SIDEBAR_W, String(Math.round(w)));
|
||||
},
|
||||
});
|
||||
sidebarResizer?.addEventListener('mousedown', () => {
|
||||
this.sidebarStartW = 0;
|
||||
});
|
||||
|
||||
// spatial pane height
|
||||
const spatialResizer = document.querySelector<HTMLElement>('#spatial-resizer');
|
||||
const spatialWrap = document.querySelector<HTMLElement>('#spatial-wrap');
|
||||
const savedSpatialH = parseInt(localStorage.getItem(LS_SPATIAL_H) || '', 10);
|
||||
if (isDesktop() && savedSpatialH && spatialWrap) spatialWrap.style.height = `${savedSpatialH}px`;
|
||||
dragResize({
|
||||
handle: spatialResizer,
|
||||
axis: 'y',
|
||||
onMove: (dy) => {
|
||||
if (!this.spatialStartH && spatialWrap) this.spatialStartH = spatialWrap.getBoundingClientRect().height;
|
||||
const max = window.innerHeight * SPATIAL_MAX_RATIO;
|
||||
const h = Math.min(max, Math.max(SPATIAL_MIN, this.spatialStartH - dy));
|
||||
if (spatialWrap) spatialWrap.style.height = `${h}px`;
|
||||
localStorage.setItem(LS_SPATIAL_H, String(Math.round(h)));
|
||||
this.cb.onSpatialResize();
|
||||
},
|
||||
});
|
||||
spatialResizer?.addEventListener('mousedown', () => {
|
||||
this.spatialStartH = 0;
|
||||
});
|
||||
}
|
||||
|
||||
private wireCollapse(): void {
|
||||
// sidebar collapse/expand
|
||||
const btnSidebarCollapse = document.querySelector<HTMLElement>('#btn-sidebar-collapse');
|
||||
const btnSidebarExpand = document.querySelector<HTMLElement>('#btn-sidebar-expand');
|
||||
const setSidebarCollapsed = (collapsed: boolean) => {
|
||||
document.body.classList.toggle('sidebar-collapsed', collapsed);
|
||||
localStorage.setItem(LS_SIDEBAR_COLLAPSED, collapsed ? '1' : '0');
|
||||
};
|
||||
btnSidebarCollapse?.addEventListener('click', () => setSidebarCollapsed(true));
|
||||
btnSidebarExpand?.addEventListener('click', () => setSidebarCollapsed(false));
|
||||
if (isDesktop() && localStorage.getItem(LS_SIDEBAR_COLLAPSED) === '1') setSidebarCollapsed(true);
|
||||
|
||||
// spatial pane collapse/expand
|
||||
const btnSpatialCollapse = document.querySelector<HTMLElement>('#btn-spatial-collapse');
|
||||
const btnSpatialExpand = document.querySelector<HTMLElement>('#btn-spatial-expand');
|
||||
const setSpatialCollapsed = (collapsed: boolean) => {
|
||||
document.body.classList.toggle('spatial-collapsed', collapsed);
|
||||
localStorage.setItem(LS_SPATIAL_COLLAPSED, collapsed ? '1' : '0');
|
||||
if (!collapsed) requestAnimationFrame(() => this.cb.onSpatialExpand());
|
||||
this.cb.applyGridLayout();
|
||||
};
|
||||
btnSpatialCollapse?.addEventListener('click', () => setSpatialCollapsed(true));
|
||||
btnSpatialExpand?.addEventListener('click', () => setSpatialCollapsed(false));
|
||||
if (localStorage.getItem(LS_SPATIAL_COLLAPSED) === '1') setSpatialCollapsed(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { MAX_USER_VOLUME } from '../constants';
|
||||
import { mIcon } from './elements';
|
||||
import type { AppState } from '../state';
|
||||
import type { VoiceAppContext } from '../context';
|
||||
import type { PeerSession } from '../webrtc/peer-session';
|
||||
import type { PeerSnapshot } from '../types';
|
||||
|
||||
/**
|
||||
* Sidebar channel rosters: the live room (me + peers with per-user volume
|
||||
* popups) and read-only snapshots of the other channels.
|
||||
*/
|
||||
export class RosterUi {
|
||||
/** roster row for "me", kept so speaking highlights don't need a rebuild */
|
||||
private meRosterEl: HTMLElement | null = null;
|
||||
|
||||
constructor(private readonly ctx: VoiceAppContext) {}
|
||||
|
||||
private get state(): AppState {
|
||||
return this.ctx.state;
|
||||
}
|
||||
|
||||
renderChannelCounts(): void {
|
||||
const counts: Record<string, number> = {};
|
||||
// Count users across all rooms known from snapshot
|
||||
for (const [rId, users] of Object.entries(this.state.channelRosters || {})) {
|
||||
counts[rId] = users ? users.length : 0;
|
||||
}
|
||||
// If active room has peers, ensure active count includes me + peers
|
||||
if (this.state.room) {
|
||||
counts[this.state.room] = this.state.peers.size + 1;
|
||||
}
|
||||
document.querySelectorAll<HTMLElement>('[data-room-count]').forEach((el) => {
|
||||
const n = counts[el.dataset.roomCount || ''] || 0;
|
||||
el.textContent = n ? `${n}` : '';
|
||||
});
|
||||
}
|
||||
|
||||
renderTiles(): void {
|
||||
const state = this.state;
|
||||
// Remember an open volume popup so it survives this rebuild (peer join/leave
|
||||
// or a mute/share badge change) without snapping closed under the cursor.
|
||||
const openVolPeerId =
|
||||
document.querySelector<HTMLElement>('.channel-user-item.show-vol[data-peer-id]')?.dataset.peerId || null;
|
||||
|
||||
// Clear any existing user rosters in voice channels
|
||||
document.querySelectorAll<HTMLElement>('.channel-users').forEach((el) => {
|
||||
el.innerHTML = '';
|
||||
});
|
||||
this.meRosterEl = null;
|
||||
|
||||
// For each voice channel element in the sidebar
|
||||
document.querySelectorAll<HTMLElement>('.channel.voice').forEach((channelEl) => {
|
||||
const roomId = channelEl.dataset.room;
|
||||
if (!roomId) return;
|
||||
|
||||
let roster = channelEl.nextElementSibling as HTMLElement | null;
|
||||
if (!roster || !roster.classList.contains('channel-users')) {
|
||||
roster = document.createElement('div');
|
||||
roster.className = 'channel-users';
|
||||
channelEl.after(roster);
|
||||
}
|
||||
|
||||
if (state.room && state.room === roomId) {
|
||||
this.renderActiveRoster(roster);
|
||||
} else {
|
||||
this.renderSnapshotRoster(roster, roomId);
|
||||
}
|
||||
});
|
||||
|
||||
// Re-open the volume popup on the rebuilt row for the peer whose popup was
|
||||
// open before this structural rebuild.
|
||||
if (openVolPeerId) {
|
||||
document
|
||||
.querySelector(`.channel-user-item[data-peer-id="${CSS.escape(openVolPeerId)}"]`)
|
||||
?.classList.add('show-vol');
|
||||
}
|
||||
|
||||
this.ctx.renderStageEmpty();
|
||||
}
|
||||
|
||||
toggleMeSpeaking(on: boolean): void {
|
||||
this.meRosterEl?.classList.toggle('speaking', on);
|
||||
}
|
||||
|
||||
private renderActiveRoster(roster: HTMLElement): void {
|
||||
const state = this.state;
|
||||
const meItem = document.createElement('div');
|
||||
meItem.className = 'channel-user-item';
|
||||
this.meRosterEl = meItem;
|
||||
const meAv = document.createElement('div');
|
||||
meAv.className = 'avatar user-line-avatar';
|
||||
setAvatar(meAv, state.name, state.myId || 'me');
|
||||
const meName = document.createElement('span');
|
||||
meName.className = 'user-line-name';
|
||||
meName.textContent = `${state.name} (you)`;
|
||||
|
||||
const meBadges = document.createElement('span');
|
||||
meBadges.className = 'user-line-badges';
|
||||
if (state.muted) meBadges.appendChild(badge('badge-mute', 'Muted', 'mic_off'));
|
||||
if (state.deafened) meBadges.appendChild(badge('badge-deafen', 'Deafened', 'headset_off'));
|
||||
if (state.sharing) meBadges.appendChild(badge('badge-share', 'Sharing screen', 'present_to_all'));
|
||||
|
||||
meItem.append(meAv, meName, meBadges);
|
||||
roster.appendChild(meItem);
|
||||
|
||||
for (const peer of state.peers.values()) {
|
||||
roster.appendChild(this.buildPeerItem(peer));
|
||||
}
|
||||
}
|
||||
|
||||
private buildPeerItem(peer: PeerSession): HTMLElement {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'channel-user-item' + (peer.speaking ? ' speaking' : '');
|
||||
item.dataset.peerId = peer.id;
|
||||
|
||||
const av = document.createElement('div');
|
||||
av.className = 'avatar user-line-avatar';
|
||||
setAvatar(av, peer.name, peer.id);
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'user-line-name';
|
||||
name.textContent = peer.name;
|
||||
|
||||
const badges = document.createElement('span');
|
||||
badges.className = 'user-line-badges';
|
||||
if (!peer.connected) {
|
||||
badges.innerHTML += '<span class="badge-conn" title="Connecting...">…</span>';
|
||||
}
|
||||
if (peer.state.muted) badges.appendChild(badge('badge-mute', 'Muted', 'mic_off'));
|
||||
if (peer.state.sharing) badges.appendChild(badge('badge-share', 'Sharing screen', 'present_to_all'));
|
||||
|
||||
// Volume hover popup
|
||||
const volPop = document.createElement('div');
|
||||
volPop.className = 'volume-popup';
|
||||
const volLabel = document.createElement('div');
|
||||
volLabel.className = 'vol-title';
|
||||
const applyVol = (next: number) => {
|
||||
peer.vol = next;
|
||||
volLabel.textContent = `User Volume: ${Math.round(peer.vol * 100)}%`;
|
||||
this.ctx.webrtc.applyUserVolume(peer);
|
||||
};
|
||||
applyVol(peer.vol);
|
||||
const volInput = document.createElement('input');
|
||||
volInput.type = 'range';
|
||||
volInput.min = '0';
|
||||
volInput.max = String(MAX_USER_VOLUME);
|
||||
volInput.step = '0.05';
|
||||
volInput.value = String(peer.vol);
|
||||
volInput.oninput = (e) => {
|
||||
e.stopPropagation();
|
||||
applyVol(Number(volInput.value));
|
||||
};
|
||||
// Double-click snaps back to 100%: on a 400% range it is easy to lose
|
||||
// the neutral point while dragging.
|
||||
volInput.ondblclick = (e) => {
|
||||
e.stopPropagation();
|
||||
volInput.value = '1';
|
||||
applyVol(1);
|
||||
};
|
||||
volPop.append(volLabel, volInput);
|
||||
|
||||
// Touch has no :hover — tap the row to reveal the volume popup instead.
|
||||
item.addEventListener('click', (e) => {
|
||||
if (e.target === volInput) return;
|
||||
item.classList.toggle('show-vol');
|
||||
});
|
||||
|
||||
item.append(av, name, badges, volPop);
|
||||
peer.els.rosterItem = item;
|
||||
return item;
|
||||
}
|
||||
|
||||
private renderSnapshotRoster(roster: HTMLElement, roomId: string): void {
|
||||
// Remote / unjoined channel: render users from server roster snapshot
|
||||
const occupants: PeerSnapshot[] | undefined = this.state.channelRosters?.[roomId];
|
||||
if (Array.isArray(occupants) && occupants.length > 0) {
|
||||
for (const user of occupants) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'channel-user-item';
|
||||
|
||||
const av = document.createElement('div');
|
||||
av.className = 'avatar user-line-avatar';
|
||||
setAvatar(av, user.name, user.id);
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'user-line-name';
|
||||
name.textContent = user.name;
|
||||
|
||||
const badges = document.createElement('span');
|
||||
badges.className = 'user-line-badges';
|
||||
if (user.state?.muted) badges.appendChild(badge('badge-mute', 'Muted', 'mic_off'));
|
||||
if (user.state?.sharing) badges.appendChild(badge('badge-share', 'Sharing screen', 'present_to_all'));
|
||||
|
||||
item.append(av, name, badges);
|
||||
roster.appendChild(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Check whether the "me" avatar is currently marked as speaking. */
|
||||
meSpeaking(): boolean {
|
||||
return this.meRosterEl?.classList.contains('speaking') ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
function badge(cls: string, title: string, icon: string): HTMLElement {
|
||||
const b = document.createElement('span');
|
||||
b.className = cls;
|
||||
b.title = title;
|
||||
b.appendChild(mIcon(icon));
|
||||
return b;
|
||||
}
|
||||
|
||||
export function setAvatar(el: HTMLElement, name: string, id: string): void {
|
||||
el.style.background = colorFor(id);
|
||||
el.textContent = (name || '?').slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
const PEER_COLORS = ['#5865f2', '#23a55a', '#f0b232', '#eb459e', '#3ba55c', '#faa61a', '#e67e22', '#9b59b6'];
|
||||
|
||||
export function colorFor(id: string): string {
|
||||
let h = 0;
|
||||
for (const c of id) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
||||
return PEER_COLORS[h % PEER_COLORS.length];
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { mIcon } from './elements';
|
||||
import type { AppState } from '../state';
|
||||
import type { VoiceAppContext } from '../context';
|
||||
import type { ShareInfo } from '../types';
|
||||
|
||||
interface PoppedWindow {
|
||||
win: Window;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Picks the column/row count (out of `count` tiles) that best fills a
|
||||
// width x height area assuming ~16:9 tiles, so multiple streams can be
|
||||
// watched at once with as little wasted space as possible.
|
||||
export function bestGrid(count: number, width: number, height: number): { cols: number; rows: number; area: number } {
|
||||
const aspect = 16 / 9;
|
||||
let best = { cols: count, rows: 1, area: 0 };
|
||||
for (let cols = 1; cols <= count; cols++) {
|
||||
const rows = Math.ceil(count / cols);
|
||||
let cellW = width / cols;
|
||||
let cellH = height / rows;
|
||||
if (cellW / cellH > aspect) cellW = cellH * aspect;
|
||||
else cellH = cellW / aspect;
|
||||
const area = cellW * cellH;
|
||||
if (area > best.area) best = { cols, rows, area };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Screen share stage: spotlight/grid layout, tiles and pop-out windows. */
|
||||
export class ScreensUi {
|
||||
/** clicked remote share, rendered large with the rest as a filmstrip */
|
||||
private focusedShareKey: string | null = null;
|
||||
/** share key -> popped-out window */
|
||||
private poppedWindows = new Map<string, PoppedWindow>();
|
||||
private screensResizeObserver: ResizeObserver | null = null;
|
||||
private gridRaf: number | null = null;
|
||||
|
||||
constructor(private readonly ctx: VoiceAppContext) {}
|
||||
|
||||
private get state(): AppState {
|
||||
return this.ctx.state;
|
||||
}
|
||||
|
||||
private get container(): HTMLElement | null {
|
||||
return this.ctx.el('screens');
|
||||
}
|
||||
|
||||
activeShares(): ShareInfo[] {
|
||||
const shares: ShareInfo[] = [];
|
||||
for (const [streamId, stream] of this.state.localScreenStreams) {
|
||||
shares.push({ key: `me:${streamId}`, name: `${this.state.name} (you)`, stream, local: true });
|
||||
}
|
||||
for (const peer of this.state.peers.values()) {
|
||||
if (peer.state.sharing) {
|
||||
for (const stream of peer.screenStreams.values()) {
|
||||
shares.push({ key: `${peer.id}:${stream.id}`, name: peer.name, stream });
|
||||
}
|
||||
}
|
||||
}
|
||||
return shares;
|
||||
}
|
||||
|
||||
render(): void {
|
||||
const container = this.container;
|
||||
const shares = this.activeShares();
|
||||
// prune popouts whose share ended
|
||||
const live = new Set(shares.map((s) => s.key));
|
||||
for (const [key, p] of [...this.poppedWindows]) {
|
||||
if (!live.has(key)) {
|
||||
try {
|
||||
p.win.close();
|
||||
} catch {}
|
||||
this.poppedWindows.delete(key);
|
||||
}
|
||||
}
|
||||
if (this.focusedShareKey && !live.has(this.focusedShareKey)) this.focusedShareKey = null;
|
||||
|
||||
if (!container) return;
|
||||
|
||||
const ownShares = shares.filter((s) => s.local);
|
||||
const remoteShares = shares.filter((s) => !s.local);
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
if (ownShares.length) {
|
||||
const pillRow = document.createElement('div');
|
||||
pillRow.className = 'own-pills';
|
||||
for (const share of ownShares) pillRow.appendChild(this.buildOwnPill(share));
|
||||
container.appendChild(pillRow);
|
||||
}
|
||||
|
||||
if (remoteShares.length) {
|
||||
const body = document.createElement('div');
|
||||
if (this.focusedShareKey) {
|
||||
body.className = 'screens-body spotlight';
|
||||
const focused = remoteShares.find((s) => s.key === this.focusedShareKey);
|
||||
const rest = remoteShares.filter((s) => s.key !== this.focusedShareKey);
|
||||
if (focused) body.appendChild(this.buildShareCard(focused, true));
|
||||
if (rest.length) {
|
||||
const strip = document.createElement('div');
|
||||
strip.className = 'thumbs-row';
|
||||
for (const s of rest) strip.appendChild(this.buildShareCard(s, false));
|
||||
body.appendChild(strip);
|
||||
}
|
||||
container.appendChild(body);
|
||||
} else {
|
||||
body.className = 'screens-body screens-grid';
|
||||
for (const s of remoteShares) body.appendChild(this.buildShareCard(s, false));
|
||||
container.appendChild(body);
|
||||
this.applyGridLayout();
|
||||
this.ensureResizeObserver();
|
||||
}
|
||||
}
|
||||
|
||||
this.updateTileIndicators();
|
||||
}
|
||||
|
||||
// Re-measures the live grid and updates its column/row count only — avoids
|
||||
// render()'s full teardown, which would restart <video> playback.
|
||||
applyGridLayout(): void {
|
||||
const container = this.container;
|
||||
const body = container?.querySelector<HTMLElement>('.screens-grid');
|
||||
const count = body?.children.length;
|
||||
if (!body || !count) return;
|
||||
const rect = body.getBoundingClientRect();
|
||||
const { cols, rows } = bestGrid(count, rect.width || 800, rect.height || 450);
|
||||
body.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
|
||||
body.style.gridTemplateRows = `repeat(${rows}, 1fr)`;
|
||||
}
|
||||
|
||||
private ensureResizeObserver(): void {
|
||||
if (this.screensResizeObserver || !this.container || typeof ResizeObserver === 'undefined') return;
|
||||
this.screensResizeObserver = new ResizeObserver(() => {
|
||||
if (this.gridRaf !== null) return;
|
||||
this.gridRaf = requestAnimationFrame(() => {
|
||||
this.gridRaf = null;
|
||||
this.applyGridLayout();
|
||||
});
|
||||
});
|
||||
this.screensResizeObserver.observe(this.container);
|
||||
}
|
||||
|
||||
private buildPoppedPlaceholder(share: ShareInfo): HTMLElement {
|
||||
const ph = document.createElement('div');
|
||||
ph.className = 'screen-card popped';
|
||||
ph.dataset.shareKey = share.key;
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'pop-btn';
|
||||
btn.appendChild(mIcon('open_in_new'));
|
||||
btn.appendChild(document.createTextNode(' re-embed'));
|
||||
btn.onclick = () => this.closePopout(share.key);
|
||||
const label = document.createElement('div');
|
||||
label.className = 'label';
|
||||
label.textContent = `${share.name} — popped out`;
|
||||
ph.append(btn, label);
|
||||
return ph;
|
||||
}
|
||||
|
||||
private buildShareCard(share: ShareInfo, focused: boolean): HTMLElement {
|
||||
if (this.poppedWindows.has(share.key)) return this.buildPoppedPlaceholder(share);
|
||||
const card = document.createElement('div');
|
||||
card.className = 'screen-card' + (focused ? ' focused' : '');
|
||||
card.dataset.shareKey = share.key;
|
||||
card.title = focused ? 'Click to exit spotlight' : 'Click to watch this stream large';
|
||||
const video = document.createElement('video');
|
||||
video.autoplay = true;
|
||||
video.playsInline = true;
|
||||
video.muted = !!share.local;
|
||||
video.srcObject = share.stream;
|
||||
video.play().catch(() => {});
|
||||
const label = document.createElement('div');
|
||||
label.className = 'label';
|
||||
label.innerHTML = '<span class="live">●</span>';
|
||||
label.appendChild(document.createTextNode(share.name));
|
||||
const micBadge = document.createElement('span');
|
||||
micBadge.className = 'tile-mic-badge';
|
||||
micBadge.appendChild(mIcon('mic_off'));
|
||||
label.appendChild(micBadge);
|
||||
const pop = document.createElement('button');
|
||||
pop.className = 'pop-btn';
|
||||
pop.title = 'Pop out in new window';
|
||||
pop.appendChild(mIcon('open_in_new'));
|
||||
pop.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.popOutShare(share);
|
||||
};
|
||||
card.append(video, label, pop);
|
||||
card.addEventListener('click', (e) => {
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
this.focusedShareKey = this.focusedShareKey === share.key ? null : share.key;
|
||||
this.render();
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
private buildOwnPill(share: ShareInfo): HTMLElement {
|
||||
const pill = document.createElement('div');
|
||||
pill.className = 'own-pill';
|
||||
pill.dataset.shareKey = share.key;
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'live';
|
||||
dot.innerHTML = '●';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'own-pill-label';
|
||||
label.textContent = 'You are sharing';
|
||||
const stop = document.createElement('button');
|
||||
stop.className = 'own-pill-close';
|
||||
stop.title = 'Stop screen share';
|
||||
stop.appendChild(mIcon('close'));
|
||||
stop.onclick = () => this.ctx.screenShare.stop(share.stream);
|
||||
pill.append(dot, label, stop);
|
||||
return pill;
|
||||
}
|
||||
|
||||
// Cheap per-tick refresh of speaking/muted state on screen-share tiles —
|
||||
// avoids render()'s full teardown, which would restart <video> playback.
|
||||
updateTileIndicators(): void {
|
||||
const container = this.container;
|
||||
if (!container) return;
|
||||
for (const card of container.querySelectorAll<HTMLElement>('.screen-card[data-share-key]')) {
|
||||
const key = card.dataset.shareKey || '';
|
||||
let speaking = false;
|
||||
let muted = false;
|
||||
if (key.startsWith('me:')) {
|
||||
speaking = this.ctx.isMeSpeaking();
|
||||
muted = this.state.muted;
|
||||
} else {
|
||||
const peer = this.state.peers.get(key.split(':', 1)[0]);
|
||||
if (peer) {
|
||||
speaking = peer.speaking;
|
||||
muted = peer.state.muted;
|
||||
}
|
||||
}
|
||||
card.classList.toggle('speaking', speaking);
|
||||
const badge = card.querySelector<HTMLElement>('.tile-mic-badge');
|
||||
if (badge) badge.classList.toggle('muted', muted);
|
||||
}
|
||||
}
|
||||
|
||||
popOutShare(share: ShareInfo): void {
|
||||
if (this.poppedWindows.has(share.key)) return;
|
||||
const win = window.open('', `share-${share.key}`, 'width=960,height=540');
|
||||
if (!win) {
|
||||
this.ctx.toast('Pop-out blocked by the browser — allow pop-ups for this page', 'error');
|
||||
return;
|
||||
}
|
||||
win.document.write(`<!doctype html><title>${share.name} — screen share</title>
|
||||
<style>html,body{margin:0;height:100%;background:#000;display:grid}video{width:100%;height:100%;object-fit:contain}</style>
|
||||
<video autoplay playsinline></video>`);
|
||||
win.document.close();
|
||||
const v = win.document.querySelector('video');
|
||||
if (v) {
|
||||
(v as HTMLVideoElement).muted = !!share.local;
|
||||
(v as HTMLVideoElement).srcObject = share.stream;
|
||||
(v as HTMLVideoElement).play().catch(() => {});
|
||||
}
|
||||
this.poppedWindows.set(share.key, { win, name: share.name });
|
||||
win.addEventListener('beforeunload', () => {
|
||||
this.poppedWindows.delete(share.key);
|
||||
this.render();
|
||||
});
|
||||
this.render();
|
||||
}
|
||||
|
||||
closePopout(key: string): void {
|
||||
const p = this.poppedWindows.get(key);
|
||||
if (p) {
|
||||
this.poppedWindows.delete(key);
|
||||
try {
|
||||
p.win.close();
|
||||
} catch {}
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { LS_ICE, LS_SCREEN_HINT } from '../constants';
|
||||
import { MAX_MIC_GAIN, MAX_MIC_GAIN_HIFI } from '../media/audio-manager';
|
||||
import { toast } from './elements';
|
||||
import type { VoiceAppContext } from '../context';
|
||||
|
||||
/** Voice & ICE settings modal: mic device/gain, screen hint, STUN/TURN override. */
|
||||
export class SettingsDialog {
|
||||
constructor(private readonly ctx: VoiceAppContext) {
|
||||
const micGainInput = document.querySelector<HTMLInputElement>('#mic-gain');
|
||||
// Ceiling comes from the AudioManager profile, so the slider can never offer a
|
||||
// gain the engine would silently reject.
|
||||
if (micGainInput) {
|
||||
micGainInput.max = String(this.ctx.audio.maxGain);
|
||||
micGainInput.oninput = (ev) => {
|
||||
this.ctx.audio.setGain((ev.target as HTMLInputElement).value);
|
||||
this.updateMicGainUi();
|
||||
};
|
||||
}
|
||||
const screenHint = document.querySelector<HTMLSelectElement>('#screen-hint');
|
||||
if (screenHint) {
|
||||
screenHint.onchange = (ev) => {
|
||||
localStorage.setItem(LS_SCREEN_HINT, (ev.target as HTMLSelectElement).value);
|
||||
toast('Screen-share hint saved — applies next time you share', 'ok');
|
||||
};
|
||||
}
|
||||
const audioInput = document.querySelector<HTMLSelectElement>('#audio-input');
|
||||
if (audioInput) audioInput.onchange = (ev) => void this.ctx.mic.switchAudioInput((ev.target as HTMLSelectElement).value);
|
||||
|
||||
document.querySelector<HTMLButtonElement>('#ice-cancel')!.onclick = () =>
|
||||
document.querySelector<HTMLElement>('#modal-backdrop')!.classList.add('hidden');
|
||||
const backdrop = document.querySelector<HTMLElement>('#modal-backdrop');
|
||||
if (backdrop) {
|
||||
backdrop.onclick = (ev) => {
|
||||
if (ev.target === backdrop) backdrop.classList.add('hidden');
|
||||
};
|
||||
}
|
||||
document.querySelector<HTMLButtonElement>('#ice-reset')!.onclick = () => this.resetIceSettings();
|
||||
document.querySelector<HTMLButtonElement>('#btn-reset-ice-mobile')!.onclick = () => {
|
||||
toast('Resetting saved ICE settings…', 'ok');
|
||||
this.resetIceSettings(true);
|
||||
};
|
||||
document.querySelector<HTMLButtonElement>('#ice-save')!.onclick = () => this.save();
|
||||
}
|
||||
|
||||
open(): void {
|
||||
void this.ctx.mic.populateAudioInputs();
|
||||
const gain = document.querySelector<HTMLInputElement>('#mic-gain');
|
||||
if (gain) gain.value = String(this.ctx.audio.gain);
|
||||
this.updateMicGainUi();
|
||||
const screenHint = document.querySelector<HTMLSelectElement>('#screen-hint');
|
||||
if (screenHint) screenHint.value = localStorage.getItem(LS_SCREEN_HINT) || '';
|
||||
const override = localStorage.getItem(LS_ICE);
|
||||
const overrideEnabled = document.querySelector<HTMLInputElement>('#override-enabled');
|
||||
if (overrideEnabled) overrideEnabled.checked = !!override;
|
||||
const current = override ? JSON.parse(override) : this.ctx.webrtc.effectiveIce();
|
||||
const iceJson = document.querySelector<HTMLTextAreaElement>('#ice-json');
|
||||
if (iceJson) iceJson.value = JSON.stringify(current, null, 2);
|
||||
this.ctx.fetchIce().then(
|
||||
(server) => {
|
||||
const pre = document.querySelector<HTMLElement>('#ice-server-default');
|
||||
if (pre) pre.textContent = JSON.stringify(server, null, 2);
|
||||
},
|
||||
() => {
|
||||
const pre = document.querySelector<HTMLElement>('#ice-server-default');
|
||||
if (pre) pre.textContent = '(unavailable)';
|
||||
},
|
||||
);
|
||||
document.querySelector<HTMLElement>('#modal-backdrop')?.classList.remove('hidden');
|
||||
}
|
||||
|
||||
updateMicGainUi(): void {
|
||||
const gain = this.ctx.audio.gain;
|
||||
const input = document.querySelector<HTMLInputElement>('#mic-gain');
|
||||
const value = document.querySelector<HTMLElement>('#mic-gain-value');
|
||||
// The ceiling follows the active room profile (400% normal, 800% Hi-Fi).
|
||||
if (input) {
|
||||
input.max = String(this.ctx.audio.maxGain);
|
||||
input.value = String(gain);
|
||||
}
|
||||
if (value) value.textContent = `${Math.round(gain * 100)}%`;
|
||||
const hint = document.querySelector<HTMLElement>('#mic-gain-hint');
|
||||
if (hint) {
|
||||
hint.textContent = this.ctx.audio.hifi
|
||||
? `Hi-Fi room: raw capture, no AGC — up to ${Math.round(this.ctx.audio.maxGain * 100)}% here.`
|
||||
: `This room allows up to ${Math.round(MAX_MIC_GAIN * 100)}%. Hi-Fi rooms (Code, Gaming) capture raw with no AGC and allow up to ${Math.round(MAX_MIC_GAIN_HIFI * 100)}%.`;
|
||||
}
|
||||
}
|
||||
|
||||
private resetIceSettings(reload = false): void {
|
||||
localStorage.removeItem(LS_ICE);
|
||||
const overrideEnabled = document.querySelector<HTMLInputElement>('#override-enabled');
|
||||
if (overrideEnabled) overrideEnabled.checked = false;
|
||||
this.ctx
|
||||
.fetchIce()
|
||||
.then((server) => {
|
||||
const iceJson = document.querySelector<HTMLTextAreaElement>('#ice-json');
|
||||
if (iceJson) iceJson.value = JSON.stringify(server, null, 2);
|
||||
if (reload) location.reload();
|
||||
})
|
||||
.catch(() => {
|
||||
if (reload) location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
private save(): void {
|
||||
const enabled = document.querySelector<HTMLInputElement>('#override-enabled')!.checked;
|
||||
let parsed: unknown = null;
|
||||
if (enabled) {
|
||||
try {
|
||||
parsed = JSON.parse(document.querySelector<HTMLTextAreaElement>('#ice-json')!.value);
|
||||
if (!Array.isArray((parsed as { iceServers?: unknown }).iceServers)) {
|
||||
throw new Error('missing "iceServers" array');
|
||||
}
|
||||
} catch (err) {
|
||||
toast(`Invalid ICE JSON: ${(err as Error).message}`, 'error');
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(LS_ICE, JSON.stringify(parsed));
|
||||
} else {
|
||||
localStorage.removeItem(LS_ICE);
|
||||
}
|
||||
document.querySelector<HTMLElement>('#modal-backdrop')?.classList.add('hidden');
|
||||
toast(enabled ? 'Custom ICE saved — rejoin to apply' : 'Reverted to server ICE — rejoin to apply', 'ok');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import cytoscape from 'cytoscape';
|
||||
import type { Core, EventObject, Position } from 'cytoscape';
|
||||
|
||||
import { ROOM_UNITS, SPATIAL_PX_SCALE } from '../constants';
|
||||
import { toast } from './elements';
|
||||
import { colorFor } from './roster-ui';
|
||||
import type { VoiceAppContext } from '../context';
|
||||
|
||||
/**
|
||||
* Spatial canvas (Cytoscape.js) — graph visualization with auto-zoom-to-bounds,
|
||||
* smooth node dragging, distance indicators, and speaking/hover highlights.
|
||||
*/
|
||||
export class SpatialMap {
|
||||
private cy: Core;
|
||||
/** set explicitly in grab/free — not re-derived via ':grabbed' each tick */
|
||||
private draggedNodeId: string | null = null;
|
||||
private lastPeerCount = -1;
|
||||
private syncTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
constructor(private readonly ctx: VoiceAppContext, container: HTMLElement) {
|
||||
this.cy = cytoscape({
|
||||
container,
|
||||
boxSelectionEnabled: false,
|
||||
autounselectify: false,
|
||||
userZoomingEnabled: true,
|
||||
userPanningEnabled: true,
|
||||
minZoom: 0.2,
|
||||
maxZoom: 3.5,
|
||||
style: [
|
||||
{
|
||||
selector: 'node',
|
||||
style: {
|
||||
shape: 'ellipse',
|
||||
width: 34,
|
||||
height: 34,
|
||||
'background-color': 'data(color)',
|
||||
label: 'data(label)',
|
||||
'text-valign': 'bottom',
|
||||
'text-margin-y': 6,
|
||||
'font-size': '11px',
|
||||
'font-family': 'system-ui, -apple-system, sans-serif',
|
||||
color: '#80848e',
|
||||
'text-background-opacity': 0.7,
|
||||
'text-background-color': '#1e1f22',
|
||||
'text-background-padding': '2px',
|
||||
'text-background-shape': 'roundrectangle',
|
||||
'border-width': 2,
|
||||
'border-color': 'rgba(255,255,255,0.15)',
|
||||
'transition-property': 'border-color, border-width, width, height',
|
||||
'transition-duration': '0.15s',
|
||||
} as unknown as cytoscape.Css.Node,
|
||||
},
|
||||
{
|
||||
selector: 'node[?isMe]',
|
||||
style: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
color: '#b5bac1',
|
||||
'font-weight': 'bold',
|
||||
'border-width': 3,
|
||||
'border-color': 'rgba(255,255,255,0.4)',
|
||||
} as unknown as cytoscape.Css.Node,
|
||||
},
|
||||
{
|
||||
selector: 'node.speaking',
|
||||
style: {
|
||||
'border-color': '#23a55a',
|
||||
'border-width': 4,
|
||||
} as unknown as cytoscape.Css.Node,
|
||||
},
|
||||
{
|
||||
selector: 'node.muted',
|
||||
style: {
|
||||
'border-color': '#f23f43',
|
||||
'border-width': 3,
|
||||
} as unknown as cytoscape.Css.Node,
|
||||
},
|
||||
{
|
||||
selector: 'node:selected',
|
||||
style: {
|
||||
'border-color': '#5865f2',
|
||||
'border-width': 4,
|
||||
} as unknown as cytoscape.Css.Node,
|
||||
},
|
||||
{
|
||||
selector: 'edge',
|
||||
style: {
|
||||
width: 1,
|
||||
'line-color': 'rgba(88, 101, 242, 0.25)',
|
||||
'line-style': 'dashed',
|
||||
'curve-style': 'bezier',
|
||||
label: 'data(label)',
|
||||
'font-size': '9px',
|
||||
color: '#80848e',
|
||||
'text-background-opacity': 0.6,
|
||||
'text-background-color': '#111214',
|
||||
'text-background-padding': '2px',
|
||||
} as unknown as cytoscape.Css.Edge,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
this.cy.on('grab', 'node', (evt) => this.onGrab(evt));
|
||||
this.cy.on('drag', 'node', (evt) => this.onDrag(evt));
|
||||
this.cy.on('free', 'node', (evt) => this.onFree(evt));
|
||||
|
||||
// Auto resize on container size change
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.cy.resize();
|
||||
this.zoomToBounds(false);
|
||||
});
|
||||
this.resizeObserver.observe(container);
|
||||
|
||||
this.syncTimer = setInterval(() => this.syncGraph(), 100);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.syncTimer) clearInterval(this.syncTimer);
|
||||
this.resizeObserver?.disconnect();
|
||||
this.cy.destroy();
|
||||
}
|
||||
|
||||
resize(): void {
|
||||
this.cy.resize();
|
||||
}
|
||||
|
||||
fit(): void {
|
||||
this.zoomToBounds(true);
|
||||
}
|
||||
|
||||
// Convert spatial room units (0..100) to Cytoscape viewport pixels (scaled around center)
|
||||
private roomToCyPos(x: number, y: number): Position {
|
||||
return { x: (x - 50) * SPATIAL_PX_SCALE, y: (y - 50) * SPATIAL_PX_SCALE };
|
||||
}
|
||||
|
||||
private cyToRoomPos(pos: Position): { x: number; y: number } | null {
|
||||
const x = Math.min(ROOM_UNITS, Math.max(0, pos.x / SPATIAL_PX_SCALE + 50));
|
||||
const y = Math.min(ROOM_UNITS, Math.max(0, pos.y / SPATIAL_PX_SCALE + 50));
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return null; // never let NaN/Infinity leak into state
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
// Fit / zoom viewport to all active users with comfortable padding
|
||||
zoomToBounds(animate = true): void {
|
||||
const nodes = this.cy.nodes();
|
||||
if (nodes.length === 0) return;
|
||||
if (animate) {
|
||||
this.cy.stop();
|
||||
this.cy.animate({
|
||||
fit: { eles: nodes, padding: 60 },
|
||||
duration: 350,
|
||||
easing: 'ease-out-cubic',
|
||||
} as cytoscape.AnimationOptions);
|
||||
} else {
|
||||
this.cy.fit(nodes, 60);
|
||||
}
|
||||
}
|
||||
|
||||
private onGrab(evt: EventObject): void {
|
||||
this.cy.stop(); // cancel any in-flight fit animation so it can't fight the drag's pointer->model mapping
|
||||
this.draggedNodeId = evt.target.id();
|
||||
if (!evt.target.data('isMe')) {
|
||||
toast(
|
||||
`Repositioning ${evt.target.data('label')} — this only changes how loud/where YOU hear them, not their real position`,
|
||||
'warn',
|
||||
);
|
||||
}
|
||||
(evt.target.cy().container() as HTMLElement)?.classList.add('dragging');
|
||||
}
|
||||
|
||||
private onDrag(evt: EventObject): void {
|
||||
const node = evt.target;
|
||||
const pos = this.cyToRoomPos(node.position());
|
||||
if (!pos) return; // guard: never propagate a bad (NaN/Infinity) position into shared state
|
||||
// cyToRoomPos already clamped pos to the room — snap the node's actual on-screen
|
||||
// position back to that clamp too. Without this, Cytoscape lets the dot follow the
|
||||
// pointer arbitrarily far past the edge (nothing was clamping node.position() itself,
|
||||
// only the derived room coordinate), so it visually flew off while the stored
|
||||
// position silently stayed pinned at the wall — then snapped back the instant you
|
||||
// let go. This keeps everyone within the room, which given refDistance=4 /
|
||||
// rolloffFactor=1.4 always keeps audio gain above ~18%, i.e. nobody can drag
|
||||
// themselves or a peer out of hearing range.
|
||||
node.position(this.roomToCyPos(pos.x, pos.y));
|
||||
const state = this.ctx.state;
|
||||
if (node.data('isMe')) {
|
||||
state.me.x = pos.x;
|
||||
state.me.y = pos.y;
|
||||
this.ctx.sendMyPos();
|
||||
} else {
|
||||
// Peer dragged: update local audio position relative to me — never sent to anyone else
|
||||
const peer = state.peers.get(node.id());
|
||||
if (peer) {
|
||||
peer.x = pos.x;
|
||||
peer.y = pos.y;
|
||||
this.ctx.updatePanner(peer);
|
||||
}
|
||||
}
|
||||
this.updateEdges();
|
||||
}
|
||||
|
||||
private onFree(evt: EventObject): void {
|
||||
this.draggedNodeId = null;
|
||||
(evt.target.cy().container() as HTMLElement)?.classList.remove('dragging');
|
||||
if (evt.target.data('isMe')) {
|
||||
this.ctx.sendMyPos(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Update connecting audio distance edges between 'me' and peers
|
||||
private updateEdges(): void {
|
||||
const meNode = this.cy.getElementById('me');
|
||||
if (!meNode || meNode.empty()) return;
|
||||
|
||||
for (const peer of this.ctx.state.peers.values()) {
|
||||
const peerNode = this.cy.getElementById(peer.id);
|
||||
if (!peerNode || peerNode.empty()) continue;
|
||||
const edgeId = `edge-me-${peer.id}`;
|
||||
let edge = this.cy.getElementById(edgeId);
|
||||
|
||||
// Calculate 2D distance
|
||||
const dist = Math.hypot(peer.x - this.ctx.state.me.x, peer.y - this.ctx.state.me.y);
|
||||
const distText = `${Math.round(dist)}m`;
|
||||
|
||||
if (edge.empty()) {
|
||||
this.cy.add({
|
||||
group: 'edges',
|
||||
data: { id: edgeId, source: 'me', target: peer.id, label: distText },
|
||||
});
|
||||
} else {
|
||||
edge.data('label', distText);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove orphan edges
|
||||
this.cy.edges().forEach((edge) => {
|
||||
const peerId = edge.data('target');
|
||||
if (!this.ctx.state.peers.has(peerId)) {
|
||||
edge.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Periodic upsert of the graph from shared state. */
|
||||
syncGraph(): void {
|
||||
const state = this.ctx.state;
|
||||
const meId = state.myId || 'me';
|
||||
const meSpeaking = this.ctx.isMeSpeaking();
|
||||
|
||||
// Upsert 'me' node
|
||||
let meNode = this.cy.getElementById('me');
|
||||
if (meNode.empty()) {
|
||||
meNode = this.cy.add({
|
||||
group: 'nodes',
|
||||
data: {
|
||||
id: 'me',
|
||||
label: `${state.name || 'me'} (you)`,
|
||||
color: colorFor(meId),
|
||||
isMe: true,
|
||||
},
|
||||
position: this.roomToCyPos(state.me.x, state.me.y),
|
||||
});
|
||||
} else {
|
||||
meNode.data('label', `${state.name || 'me'} (you)`);
|
||||
meNode.data('color', colorFor(meId));
|
||||
if (this.draggedNodeId !== 'me') {
|
||||
meNode.position(this.roomToCyPos(state.me.x, state.me.y));
|
||||
}
|
||||
}
|
||||
meNode.toggleClass('speaking', meSpeaking);
|
||||
meNode.toggleClass('muted', !!state.muted);
|
||||
|
||||
// Upsert peers
|
||||
const currentPeerIds = new Set<string>();
|
||||
for (const peer of state.peers.values()) {
|
||||
currentPeerIds.add(peer.id);
|
||||
let peerNode = this.cy.getElementById(peer.id);
|
||||
if (peerNode.empty()) {
|
||||
peerNode = this.cy.add({
|
||||
group: 'nodes',
|
||||
data: {
|
||||
id: peer.id,
|
||||
label: peer.name,
|
||||
color: colorFor(peer.id),
|
||||
isMe: false,
|
||||
},
|
||||
position: this.roomToCyPos(peer.x, peer.y),
|
||||
});
|
||||
} else {
|
||||
peerNode.data('label', peer.name);
|
||||
peerNode.data('color', colorFor(peer.id));
|
||||
if (this.draggedNodeId !== peer.id) {
|
||||
peerNode.position(this.roomToCyPos(peer.x, peer.y));
|
||||
}
|
||||
}
|
||||
peerNode.toggleClass('speaking', !!peer.speaking);
|
||||
peerNode.toggleClass('muted', !!peer.state?.muted);
|
||||
}
|
||||
|
||||
// Remove nodes that have left
|
||||
this.cy.nodes().forEach((node) => {
|
||||
const id = node.id();
|
||||
if (id !== 'me' && !currentPeerIds.has(id)) {
|
||||
node.remove();
|
||||
}
|
||||
});
|
||||
|
||||
this.updateEdges();
|
||||
|
||||
// Auto-fit / zoom to bounds whenever participants count changes
|
||||
const totalCount = state.peers.size + 1;
|
||||
if (totalCount !== this.lastPeerCount) {
|
||||
this.lastPeerCount = totalCount;
|
||||
setTimeout(() => this.zoomToBounds(true), 50);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { AppState } from '../state';
|
||||
import type { VoiceAppContext } from '../context';
|
||||
|
||||
/**
|
||||
* Speaking detection loop (150 ms): local mic meter, per-peer analyser RMS and
|
||||
* screen tile mic badges.
|
||||
*
|
||||
* Toggles speaking highlights in place. A full renderTiles() here used to wipe
|
||||
* and rebuild the roster DOM on every start/stop of speech — which destroyed
|
||||
* the volume popup and its range input mid-drag, making the per-user volume
|
||||
* control feel broken exactly while a peer was talking.
|
||||
*/
|
||||
export class SpeakingMonitor {
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private meterBuf = new Uint8Array(256);
|
||||
|
||||
constructor(private readonly ctx: VoiceAppContext) {}
|
||||
|
||||
start(): void {
|
||||
if (this.timer) return;
|
||||
this.timer = setInterval(() => this.tick(), 150);
|
||||
}
|
||||
|
||||
private tick(): void {
|
||||
const state = this.ctx.state;
|
||||
if (!state.audioCtx) return;
|
||||
const localLevel = this.ctx.audio.getLevel();
|
||||
this.ctx.updateMicSignal(localLevel);
|
||||
const meSpeaking = !!state.micStream && !state.muted && localLevel.rms > 0.04;
|
||||
this.ctx.setMeAvatarSpeaking(meSpeaking);
|
||||
this.ctx.roster?.toggleMeSpeaking(meSpeaking);
|
||||
for (const peer of state.peers.values()) {
|
||||
const speaking = !!(peer.analyser && !state.deafened && this.ctx.spatial.peerRms(peer) > 0.03);
|
||||
if (speaking === peer.speaking) continue;
|
||||
peer.speaking = speaking;
|
||||
peer.els.rosterItem?.classList.toggle('speaking', speaking);
|
||||
}
|
||||
this.ctx.screens?.updateTileIndicators();
|
||||
}
|
||||
|
||||
/** RMS of the local mic meter buffer for external consumers. */
|
||||
localRms(): number {
|
||||
return this.ctx.audio.getLevel().rms;
|
||||
}
|
||||
|
||||
rmsFrom(buf: Uint8Array): number {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
const v = (buf[i] - 128) / 128;
|
||||
sum += v * v;
|
||||
}
|
||||
return Math.sqrt(sum / buf.length);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { LS_NAME } from '../constants';
|
||||
import { setAvatar } from './roster-ui';
|
||||
import type { VoiceAppContext } from '../context';
|
||||
|
||||
/** Welcome modal: required nickname on first visit + how-to quick start. */
|
||||
export class WelcomeDialog {
|
||||
private required = false;
|
||||
|
||||
constructor(private readonly ctx: VoiceAppContext) {
|
||||
const els = this.ctx.els;
|
||||
document.querySelector<HTMLButtonElement>('#welcome-continue')!.onclick = () => this.submit();
|
||||
els.welcomeNick?.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter') this.submit();
|
||||
});
|
||||
els.welcomeBackdrop?.addEventListener('click', (ev) => {
|
||||
if (ev.target === els.welcomeBackdrop && !this.required) this.close();
|
||||
});
|
||||
document.querySelector<HTMLButtonElement>('#btn-help')!.onclick = () => this.open(false);
|
||||
}
|
||||
|
||||
private get ctxEls() {
|
||||
return this.ctx.els;
|
||||
}
|
||||
|
||||
open(required: boolean): void {
|
||||
this.required = required;
|
||||
const els = this.ctxEls;
|
||||
if (els.welcomeNick) els.welcomeNick.value = this.ctx.state.name;
|
||||
els.welcomeNick?.classList.remove('invalid');
|
||||
els.welcomeNickError?.classList.add('hidden');
|
||||
els.welcomeBackdrop?.classList.remove('hidden');
|
||||
els.welcomeNick?.focus();
|
||||
}
|
||||
|
||||
private close(): void {
|
||||
this.ctxEls.welcomeBackdrop?.classList.add('hidden');
|
||||
}
|
||||
|
||||
private submit(): void {
|
||||
const els = this.ctxEls;
|
||||
const trimmed = (els.welcomeNick?.value || '').trim();
|
||||
if (!trimmed) {
|
||||
els.welcomeNick?.classList.add('invalid');
|
||||
els.welcomeNickError?.classList.remove('hidden');
|
||||
els.welcomeNick?.focus();
|
||||
return;
|
||||
}
|
||||
this.ctx.state.name = trimmed;
|
||||
if (els.nick) els.nick.value = trimmed;
|
||||
localStorage.setItem(LS_NAME, trimmed);
|
||||
if (els.meAvatar) setAvatar(els.meAvatar, trimmed, this.ctx.state.myId || 'me');
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface Window {
|
||||
/** POC debug handle: exposes live app state for console poking. */
|
||||
__dbg?: unknown;
|
||||
rum?: {
|
||||
traceHeaders?: () => Record<string, string>;
|
||||
};
|
||||
OtelRUM?: {
|
||||
last?: {
|
||||
captureMessage?: (message: string, severity: string, extra?: Record<string, unknown>) => void;
|
||||
};
|
||||
};
|
||||
__APP_RELEASE__?: string;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { IcePathInfo, PeerState, PeerSnapshot } from '../types';
|
||||
|
||||
/** One remote participant: connection, media, spatial nodes and UI refs. */
|
||||
export class PeerSession {
|
||||
readonly id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
state: PeerState;
|
||||
|
||||
pc: RTCPeerConnection | null = null;
|
||||
polite = false;
|
||||
makingOffer = false;
|
||||
ignoreOffer = false;
|
||||
pendingCandidates: RTCIceCandidateInit[] = [];
|
||||
micStream: MediaStream | null = null;
|
||||
screenStreams = new Map<string, MediaStream>();
|
||||
|
||||
// spatial playback chain (owned by SpatialAudio, stored per peer)
|
||||
gain: GainNode | null = null;
|
||||
panner: PannerNode | null = null;
|
||||
analyser: AnalyserNode | null = null;
|
||||
limiter: DynamicsCompressorNode | null = null;
|
||||
|
||||
vol: number;
|
||||
speaking = false;
|
||||
resetAttempts = 0;
|
||||
lastIceNotice: { key: string; at: number } | null = null;
|
||||
icePath: IcePathInfo | null = null;
|
||||
qualityTimer: ReturnType<typeof setInterval> | null = null;
|
||||
lastQualityNotice = 0;
|
||||
audioEl: HTMLAudioElement | null = null;
|
||||
els: { rosterItem?: HTMLElement } = {};
|
||||
|
||||
constructor(p: PeerSnapshot, initialVolume: number) {
|
||||
this.id = p.id;
|
||||
this.name = p.name;
|
||||
this.x = p.x;
|
||||
this.y = p.y;
|
||||
this.state = p.state || { muted: false, deafened: false, sharing: false };
|
||||
this.vol = initialVolume;
|
||||
}
|
||||
|
||||
get connected(): boolean {
|
||||
return this.pc?.connectionState === 'connected';
|
||||
}
|
||||
|
||||
setSharing(sharing: boolean): void {
|
||||
this.state = { ...this.state, sharing };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* SDP description shape crossing the signaling channel. The wire format is the
|
||||
* standard RTCSessionDescriptionInit (type + sdp); sdp is present on every
|
||||
* message we relay but kept optional to mirror the DOM type.
|
||||
*/
|
||||
export type RTCSessionDescriptionInitLike = RTCSessionDescriptionInit;
|
||||
@@ -0,0 +1,176 @@
|
||||
import { captureRumIssue } from '../rum';
|
||||
import { RECONNECT_DELAYS } from '../constants';
|
||||
import type { AppState } from '../state';
|
||||
import type { VoiceAppContext } from '../context';
|
||||
import type { ClientSignal } from '../types';
|
||||
|
||||
/** WebSocket signaling connection with keepalive pings. */
|
||||
export class SignalingClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(private readonly ctx: VoiceAppContext) {}
|
||||
|
||||
private get state(): AppState {
|
||||
return this.ctx.state;
|
||||
}
|
||||
|
||||
get open(): boolean {
|
||||
return this.ws?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
openSignaling(room: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const url = `${proto}://${location.host}/ws?room=${encodeURIComponent(room)}&name=${encodeURIComponent(this.state.name)}`;
|
||||
const ws = new WebSocket(url);
|
||||
this.ws = ws;
|
||||
ws.onerror = () => {
|
||||
this.state.connecting = false;
|
||||
this.ctx.setStatus('connection failed', 'error');
|
||||
captureRumIssue('Voice signaling connection failed', 'ERROR', { room });
|
||||
reject(new Error('websocket failed'));
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
let msg: unknown;
|
||||
try {
|
||||
msg = JSON.parse(ev.data as string);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.ctx.handleSignal(msg);
|
||||
} catch (err) {
|
||||
const type = (msg as { type?: string })?.type;
|
||||
console.error('signal handler error', type, err);
|
||||
captureRumIssue('Voice signaling message handler failed', 'ERROR', {
|
||||
type,
|
||||
error: (err as Error)?.message || String(err),
|
||||
});
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
this.clearPing();
|
||||
this.state.signalingOpen = false;
|
||||
if (this.ws === ws && this.state.room) {
|
||||
const room2 = this.state.room;
|
||||
this.ctx.leaveVoice('disconnected');
|
||||
this.ctx.beginReconnect(room2);
|
||||
}
|
||||
};
|
||||
ws.onopen = () => {
|
||||
this.clearPing();
|
||||
this.pingTimer = setInterval(() => this.send({ type: 'ping' }), 10000);
|
||||
this.state.signalingOpen = true;
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
send(msg: ClientSignal): void {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.clearPing();
|
||||
if (this.ws) {
|
||||
try {
|
||||
this.ws.close();
|
||||
} catch {}
|
||||
this.ws = null;
|
||||
}
|
||||
this.state.signalingOpen = false;
|
||||
}
|
||||
|
||||
private clearPing(): void {
|
||||
if (this.pingTimer) {
|
||||
clearInterval(this.pingTimer);
|
||||
this.pingTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Auto-rejoin with a countdown banner after the signaling socket drops. */
|
||||
export class ReconnectController {
|
||||
private room: string | null = null;
|
||||
private attempts = 0;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private countdownTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly ctx: VoiceAppContext,
|
||||
private readonly els: { banner: HTMLElement | null; text: HTMLElement | null; retry: HTMLElement | null; dismiss: HTMLElement | null },
|
||||
) {
|
||||
this.els.retry?.addEventListener('click', () => {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
if (this.countdownTimer) clearInterval(this.countdownTimer);
|
||||
void this.doAttempt();
|
||||
});
|
||||
this.els.dismiss?.addEventListener('click', () => this.cancel());
|
||||
}
|
||||
|
||||
begin(room: string): void {
|
||||
this.cancel();
|
||||
this.room = room;
|
||||
void this.scheduleAttempt();
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
if (this.countdownTimer) clearInterval(this.countdownTimer);
|
||||
this.timer = null;
|
||||
this.countdownTimer = null;
|
||||
this.room = null;
|
||||
this.attempts = 0;
|
||||
this.hideBanner();
|
||||
}
|
||||
|
||||
get active(): boolean {
|
||||
return this.room !== null;
|
||||
}
|
||||
|
||||
private async scheduleAttempt(): Promise<void> {
|
||||
if (!this.room) return;
|
||||
const delay = RECONNECT_DELAYS[Math.min(this.attempts, RECONNECT_DELAYS.length - 1)];
|
||||
this.attempts++;
|
||||
let remaining = delay;
|
||||
const label = () => `Connection lost — reconnecting in ${Math.ceil(remaining / 1000)}s (attempt ${this.attempts})`;
|
||||
this.showBanner(label());
|
||||
if (this.countdownTimer) clearInterval(this.countdownTimer);
|
||||
this.countdownTimer = setInterval(() => {
|
||||
remaining -= 1000;
|
||||
if (remaining <= 0) {
|
||||
if (this.countdownTimer) clearInterval(this.countdownTimer);
|
||||
this.countdownTimer = null;
|
||||
void this.doAttempt();
|
||||
return;
|
||||
}
|
||||
this.showBanner(label());
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private async doAttempt(): Promise<void> {
|
||||
if (!this.room) return;
|
||||
if (this.countdownTimer) clearInterval(this.countdownTimer);
|
||||
const room = this.room;
|
||||
this.showBanner('Reconnecting…');
|
||||
try {
|
||||
await this.ctx.joinVoice(room);
|
||||
this.room = null;
|
||||
this.attempts = 0;
|
||||
this.hideBanner();
|
||||
this.ctx.toast('Reconnected', 'ok');
|
||||
} catch {
|
||||
void this.scheduleAttempt();
|
||||
}
|
||||
}
|
||||
|
||||
private showBanner(text: string): void {
|
||||
if (this.els.text) this.els.text.textContent = text;
|
||||
this.els.banner?.classList.remove('hidden');
|
||||
}
|
||||
|
||||
private hideBanner(): void {
|
||||
this.els.banner?.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
import { captureRumIssue } from '../rum';
|
||||
import { FALLBACK_ICE, HIFI_ROOMS, ICE_NOTICE_INTERVAL, LS_ICE, LS_USER_VOLUME, MAX_USER_VOLUME, RESET_MAX_ATTEMPTS } from '../constants';
|
||||
import { MAX_MIC_GAIN, MAX_MIC_GAIN_HIFI } from '../media/audio-manager';
|
||||
import { PeerSession } from './peer-session';
|
||||
import type { VoiceAppContext } from '../context';
|
||||
import type { IcePathInfo, PeerSnapshot } from '../types';
|
||||
import type { RTCSessionDescriptionInitLike } from './sdp';
|
||||
|
||||
function mungeOpusHifi(sdp: string): string {
|
||||
const pts = [...sdp.matchAll(/a=rtpmap:(\d+) opus\/48000\/2\r?\n/g)].map((m) => m[1]);
|
||||
if (!pts.length) return sdp;
|
||||
const extra = 'stereo=1;sprop-stereo=1;maxaveragebandwidth=256000;maxplaybackrate=48000';
|
||||
for (const pt of pts) {
|
||||
if (new RegExp(`a=fmtp:${pt} `).test(sdp)) {
|
||||
sdp = sdp.replace(new RegExp(`a=fmtp:${pt} `), `a=fmtp:${pt} ${extra};`);
|
||||
} else {
|
||||
sdp = sdp.replace(new RegExp(`(a=rtpmap:${pt} opus\\/48000\\/2\\r?\\n)`), `$1a=fmtp:${pt} ${extra}\r\n`);
|
||||
}
|
||||
}
|
||||
return sdp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the WebRTC mesh: peer sessions, perfect negotiation, ICE failure
|
||||
* recovery (full pc rebuilds), quality monitoring and screen-sender bookkeeping.
|
||||
*/
|
||||
export class WebRtcManager {
|
||||
/** peerId -> Map(streamId -> RTCRtpSender[]) for our screen share tracks. */
|
||||
private screenSenders = new Map<string, Map<string, RTCRtpSender[]>>();
|
||||
|
||||
constructor(private readonly ctx: VoiceAppContext) {}
|
||||
|
||||
// --- ICE / SDP helpers ---------------------------------------------------
|
||||
|
||||
effectiveIce(): RTCConfiguration {
|
||||
const raw = localStorage.getItem(LS_ICE);
|
||||
if (raw) {
|
||||
try {
|
||||
return JSON.parse(raw) as RTCConfiguration;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return (this.ctx.state.iceServers as RTCConfiguration) || (FALLBACK_ICE as unknown as RTCConfiguration);
|
||||
}
|
||||
|
||||
roomIsHifi(room?: string | null): boolean {
|
||||
return HIFI_ROOMS.has(room ?? this.ctx.state.room ?? '');
|
||||
}
|
||||
|
||||
async tuneAudioSender(sender: RTCRtpSender): Promise<void> {
|
||||
if (!sender?.getParameters || !sender?.setParameters) return;
|
||||
const params = sender.getParameters();
|
||||
if (!params.encodings?.length) params.encodings = [{}];
|
||||
// Hi-Fi rooms get 256 kbps stereo (see HIFI_ROOMS); elsewhere 128 kbps
|
||||
// keeps Opus in its fullband LD/MD modes with headroom for FEC — 64 kbps
|
||||
// audibly compresses louder/denser speech. DTX stays off so the channel
|
||||
// doesn't pump in and out of low-bitrate comfort noise.
|
||||
params.encodings[0].maxBitrate = this.roomIsHifi() ? 256000 : 128000;
|
||||
(params.encodings[0] as RTCRtpEncodingParameters & { dtx?: boolean }).dtx = false;
|
||||
params.encodings[0].priority = 'high';
|
||||
await sender.setParameters(params);
|
||||
}
|
||||
|
||||
// Hi-Fi rooms force Opus stereo and lift the in-band bitrate cap. The sender
|
||||
// encodings' maxBitrate alone is not enough: Chrome clamps Opus to the SDP's
|
||||
// maxaveragebandwidth, and stereo only survives the wire when both sides
|
||||
// declare stereo=1 in fmtp. Applied to our local offers AND answers.
|
||||
hifiDescription(desc: RTCSessionDescriptionInitLike | null): RTCSessionDescriptionInitLike | null {
|
||||
if (!desc || !this.roomIsHifi()) return desc;
|
||||
return { type: desc.type, sdp: mungeOpusHifi(desc.sdp || '') };
|
||||
}
|
||||
|
||||
preferOpus(pc: RTCPeerConnection): void {
|
||||
const capabilities = (globalThis as Record<string, any>).RTCRtpReceiver?.getCapabilities?.('audio');
|
||||
const codecs: RTCRtpCodec[] = (capabilities?.codecs as RTCRtpCodec[]) || [];
|
||||
const opus = codecs.filter((codec) => codec.mimeType?.toLowerCase() === 'audio/opus');
|
||||
if (!opus.length) return;
|
||||
const ordered = [...opus, ...codecs.filter((codec) => !opus.includes(codec))];
|
||||
for (const transceiver of pc.getTransceivers()) {
|
||||
if (transceiver.receiver?.track?.kind === 'audio' || transceiver.sender?.track?.kind === 'audio') {
|
||||
try {
|
||||
transceiver.setCodecPreferences(ordered);
|
||||
} catch (err) {
|
||||
captureRumIssue('Could not prefer Opus codec', 'WARNING', { error: (err as Error)?.message || String(err) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async selectedIcePath(pc: RTCPeerConnection): Promise<IcePathInfo | null> {
|
||||
if (!pc?.getStats) return null;
|
||||
const stats = await pc.getStats();
|
||||
let pair: any;
|
||||
stats.forEach((report: any) => {
|
||||
if (report.type === 'candidate-pair' && (report.selected || report.nominated) && report.state === 'succeeded') {
|
||||
pair = report;
|
||||
}
|
||||
});
|
||||
if (!pair) return null;
|
||||
const local = stats.get(pair.localCandidateId) as any;
|
||||
const remote = stats.get(pair.remoteCandidateId) as any;
|
||||
return {
|
||||
localType: local?.candidateType,
|
||||
remoteType: remote?.candidateType,
|
||||
protocol: pair.protocol,
|
||||
path: local?.candidateType === 'relay' || remote?.candidateType === 'relay' ? 'TURN relay' : 'direct/STUN',
|
||||
};
|
||||
}
|
||||
|
||||
// --- per-user volume (persisted by nickname) ------------------------------
|
||||
|
||||
private savedUserVolumes(): Record<string, number> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(LS_USER_VOLUME) || '{}') || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
savedUserVolume(name: string): number {
|
||||
const n = Number(this.savedUserVolumes()[name]);
|
||||
if (!Number.isFinite(n)) return 1;
|
||||
return Math.max(0, Math.min(MAX_USER_VOLUME, n));
|
||||
}
|
||||
|
||||
rememberUserVolume(name: string, vol: number): void {
|
||||
const all = this.savedUserVolumes();
|
||||
if (Math.abs(vol - 1) < 0.001) delete all[name]; // 100% is the default — don't store noise
|
||||
else all[name] = vol;
|
||||
try {
|
||||
localStorage.setItem(LS_USER_VOLUME, JSON.stringify(all));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
applyUserVolume(peer: PeerSession): void {
|
||||
// setTargetAtTime ramps smoothly instead of zipper-clicking on drag
|
||||
const audioCtx = this.ctx.state.audioCtx;
|
||||
if (peer.gain && audioCtx && !this.ctx.state.deafened) {
|
||||
peer.gain.gain.setTargetAtTime(peer.vol, audioCtx.currentTime, 0.02);
|
||||
}
|
||||
this.rememberUserVolume(peer.name, peer.vol);
|
||||
}
|
||||
|
||||
// --- peer lifecycle --------------------------------------------------------
|
||||
|
||||
makePeerRecord(p: PeerSnapshot): PeerSession {
|
||||
return new PeerSession(p, this.savedUserVolume(p.name));
|
||||
}
|
||||
|
||||
createOrphanPeer(id: string): PeerSession {
|
||||
const p = this.makePeerRecord({ id, name: 'peer-' + id.slice(0, 4), x: 50, y: 50, state: { muted: false, deafened: false, sharing: false } });
|
||||
this.ctx.state.peers.set(id, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
ensurePC(peer: PeerSession, initiator: boolean): PeerSession {
|
||||
if (peer.pc) return peer;
|
||||
const state = this.ctx.state;
|
||||
const config: RTCConfiguration = { ...this.effectiveIce(), iceCandidatePoolSize: 4 };
|
||||
// After a failed retry, force everything through TURN relay — sidesteps
|
||||
// AP/router client isolation and NAT-hairpin issues that are common when
|
||||
// both peers are on the same LAN (direct/STUN candidates get silently
|
||||
// dropped by the router in that case, so give up trying them).
|
||||
if (peer.resetAttempts > 0) config.iceTransportPolicy = 'relay';
|
||||
const pc = new RTCPeerConnection(config);
|
||||
peer.pc = pc;
|
||||
peer.polite = (state.myId || '') > peer.id;
|
||||
|
||||
pc.onnegotiationneeded = () => void this.negotiate(peer, pc);
|
||||
pc.onicecandidate = ({ candidate }) => {
|
||||
if (candidate) this.ctx.send({ type: 'candidate', to: peer.id, candidate: candidate.toJSON() });
|
||||
};
|
||||
pc.onicecandidateerror = (event) => {
|
||||
const e = event as RTCPeerConnectionIceErrorEvent;
|
||||
this.ctx.notifyIceIssue(peer, `ICE server unavailable (${e.errorCode || 'unknown'})`, {
|
||||
peerId: peer.id,
|
||||
iceConnectionState: pc.iceConnectionState,
|
||||
iceGatheringState: pc.iceGatheringState,
|
||||
errorCode: e.errorCode,
|
||||
errorText: e.errorText,
|
||||
url: e.url,
|
||||
});
|
||||
};
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
const s = pc.iceConnectionState;
|
||||
if (s === 'disconnected') {
|
||||
this.ctx.notifyIceIssue(peer, 'ICE disconnected; audio may stop', {
|
||||
iceConnectionState: s,
|
||||
iceGatheringState: pc.iceGatheringState,
|
||||
});
|
||||
} else if (s === 'failed') {
|
||||
this.ctx.notifyIceIssue(peer, 'ICE failed; trying TURN relay', {
|
||||
iceConnectionState: s,
|
||||
iceGatheringState: pc.iceGatheringState,
|
||||
});
|
||||
}
|
||||
};
|
||||
pc.onconnectionstatechange = () => {
|
||||
this.ctx.renderTiles();
|
||||
if (pc.connectionState === 'failed') {
|
||||
console.warn('peer connection failed, rebuilding it from scratch', peer.id);
|
||||
this.ctx.notifyIceIssue(peer, 'connection failed; rebuilding voice path', {
|
||||
connectionState: pc.connectionState,
|
||||
iceConnectionState: pc.iceConnectionState,
|
||||
iceGatheringState: pc.iceGatheringState,
|
||||
signalingState: pc.signalingState,
|
||||
resetAttempts: peer.resetAttempts,
|
||||
});
|
||||
captureRumIssue('WebRTC peer connection failed', 'ERROR', {
|
||||
peerId: peer.id,
|
||||
state: pc.connectionState,
|
||||
resetAttempts: peer.resetAttempts,
|
||||
});
|
||||
this.recreatePeerConnection(peer, { announce: true });
|
||||
} else if (pc.connectionState === 'connected') {
|
||||
peer.resetAttempts = 0; // a successful connection clears the failure count
|
||||
this.selectedIcePath(pc)
|
||||
.then((path) => {
|
||||
if (!path) return;
|
||||
peer.icePath = path;
|
||||
this.ctx.toast(`${peer.name}: ${path.path} connection`, 'ok');
|
||||
captureRumIssue('WebRTC connection established', 'INFO', {
|
||||
peerId: peer.id,
|
||||
peerName: peer.name,
|
||||
connectionState: pc.connectionState,
|
||||
iceConnectionState: pc.iceConnectionState,
|
||||
...path,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
captureRumIssue('Could not inspect selected ICE path', 'WARNING', {
|
||||
peerId: peer.id,
|
||||
error: (err as Error)?.message || String(err),
|
||||
});
|
||||
});
|
||||
this.monitorPeerQuality(peer);
|
||||
}
|
||||
};
|
||||
pc.ontrack = (ev) => this.onRemoteTrack(peer, ev);
|
||||
|
||||
// send my mic; if I have none, ask to receive theirs
|
||||
if (state.micStream) {
|
||||
for (const track of state.micStream.getAudioTracks()) {
|
||||
const sender = pc.addTrack(track, state.micStream);
|
||||
this.tuneAudioSender(sender).catch((err) =>
|
||||
captureRumIssue('Could not tune Opus audio sender', 'WARNING', {
|
||||
peerId: peer.id,
|
||||
error: (err as Error)?.message || String(err),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
pc.addTransceiver('audio', { direction: 'recvonly' });
|
||||
}
|
||||
this.preferOpus(pc);
|
||||
|
||||
// send my screen share if I am currently sharing when this peer connects
|
||||
if (state.sharing) {
|
||||
const peerSenders = new Map<string, RTCRtpSender[]>();
|
||||
for (const [streamId, stream] of state.localScreenStreams) {
|
||||
const senders: RTCRtpSender[] = [];
|
||||
for (const track of stream.getTracks()) {
|
||||
senders.push(pc.addTrack(track, stream));
|
||||
}
|
||||
peerSenders.set(streamId, senders);
|
||||
}
|
||||
this.screenSenders.set(peer.id, peerSenders);
|
||||
}
|
||||
|
||||
if (initiator) void this.negotiate(peer, pc);
|
||||
return peer;
|
||||
}
|
||||
|
||||
private async negotiate(peer: PeerSession, pc: RTCPeerConnection): Promise<void> {
|
||||
try {
|
||||
peer.makingOffer = true;
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(this.hifiDescription(offer) as RTCSessionDescriptionInit);
|
||||
this.ctx.send({ type: 'offer', to: peer.id, sdp: pc.localDescription! });
|
||||
} catch (err) {
|
||||
console.error('negotiation failed', err);
|
||||
captureRumIssue('WebRTC negotiation failed', 'ERROR', { peerId: peer.id, error: (err as Error)?.message || String(err) });
|
||||
} finally {
|
||||
peer.makingOffer = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Once a connection reaches 'failed', its DTLS transport is done — restartIce()
|
||||
// only restarts ICE gathering and reuses the same transport/certificate, which
|
||||
// throws "Failed to set SSL role for the transport" if you try to renegotiate
|
||||
// on it. The only real fix is a brand-new RTCPeerConnection (fresh certificate,
|
||||
// no established role to conflict with). Since only one side may notice the
|
||||
// failure first, whichever side detects it also tells the other to rebuild via
|
||||
// a 'reset' signal — otherwise the other side keeps its old, now-mismatched
|
||||
// transport and the fresh offer we send will fail against it too.
|
||||
recreatePeerConnection(peer: PeerSession, { announce, remote = false }: { announce: boolean; remote?: boolean }): void {
|
||||
if (announce) {
|
||||
peer.resetAttempts++;
|
||||
if (peer.resetAttempts > RESET_MAX_ATTEMPTS) {
|
||||
this.ctx.toast(`Connection to ${peer.name} keeps failing — try leaving and rejoining the channel`, 'error');
|
||||
captureRumIssue('WebRTC peer connection could not be rebuilt', 'ERROR', {
|
||||
peerId: peer.id,
|
||||
resetAttempts: peer.resetAttempts,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.ctx.send({ type: 'reset', to: peer.id });
|
||||
} else if (remote) {
|
||||
// The far side detected 'failed' against us and is rebuilding. Count the
|
||||
// failure here too so our half ALSO recreates with
|
||||
// iceTransportPolicy:'relay' — before, only the detecting side forced
|
||||
// relay, so the other side kept retrying the same direct path that just
|
||||
// died and the pair burned rebuild rounds before converging.
|
||||
peer.resetAttempts++;
|
||||
if (peer.resetAttempts === RESET_MAX_ATTEMPTS + 1) {
|
||||
this.ctx.toast(`Connection to ${peer.name} keeps failing — TURN relay may be down`, 'error');
|
||||
}
|
||||
}
|
||||
if (peer.pc) {
|
||||
try {
|
||||
peer.pc.close();
|
||||
} catch {}
|
||||
}
|
||||
if (peer.qualityTimer) {
|
||||
clearInterval(peer.qualityTimer);
|
||||
peer.qualityTimer = null;
|
||||
}
|
||||
peer.pc = null;
|
||||
peer.makingOffer = false;
|
||||
peer.ignoreOffer = false;
|
||||
peer.pendingCandidates = [];
|
||||
this.ctx.spatial.detach(peer);
|
||||
if (peer.audioEl) {
|
||||
try {
|
||||
peer.audioEl.pause();
|
||||
peer.audioEl.srcObject = null;
|
||||
} catch {}
|
||||
peer.audioEl = null;
|
||||
}
|
||||
peer.micStream = null;
|
||||
peer.screenStreams.clear();
|
||||
this.screenSenders.delete(peer.id);
|
||||
this.ctx.renderScreens();
|
||||
this.ctx.renderTiles();
|
||||
// same deterministic-initiator rule as the first connection
|
||||
this.ensurePC(peer, (this.ctx.state.myId || '') < peer.id);
|
||||
}
|
||||
|
||||
async onDescription(peer: PeerSession, desc: RTCSessionDescriptionInitLike): Promise<void> {
|
||||
const pc = peer.pc!;
|
||||
const collision = desc.type === 'offer' && (peer.makingOffer || pc.signalingState !== 'stable');
|
||||
peer.ignoreOffer = !peer.polite && collision;
|
||||
if (peer.ignoreOffer) return; // impolite side ignores the rival offer
|
||||
|
||||
await pc.setRemoteDescription(desc as RTCSessionDescriptionInit); // modern browsers roll back implicitly when polite
|
||||
for (const c of peer.pendingCandidates.splice(0)) {
|
||||
await pc.addIceCandidate(c).catch((e) => {
|
||||
console.warn('buffered candidate failed', e);
|
||||
captureRumIssue('WebRTC buffered ICE candidate failed', 'WARNING', { peerId: peer.id, error: (e as Error)?.message || String(e) });
|
||||
});
|
||||
}
|
||||
if (desc.type === 'offer') {
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(this.hifiDescription(answer) as RTCSessionDescriptionInit);
|
||||
this.ctx.send({ type: 'answer', to: peer.id, sdp: pc.localDescription! });
|
||||
}
|
||||
}
|
||||
|
||||
async onCandidate(peer: PeerSession, candidate: RTCIceCandidateInit): Promise<void> {
|
||||
if (peer.ignoreOffer) return;
|
||||
if (!peer.pc || !peer.pc.remoteDescription) {
|
||||
// no pc yet: orphan peer mid-join or mid-rebuild
|
||||
peer.pendingCandidates.push(candidate);
|
||||
return;
|
||||
}
|
||||
await peer.pc.addIceCandidate(candidate);
|
||||
}
|
||||
|
||||
destroyPeer(peer: PeerSession): void {
|
||||
if (peer.pc) {
|
||||
try {
|
||||
peer.pc.close();
|
||||
} catch {}
|
||||
peer.pc = null;
|
||||
}
|
||||
if (peer.qualityTimer) {
|
||||
clearInterval(peer.qualityTimer);
|
||||
peer.qualityTimer = null;
|
||||
}
|
||||
this.ctx.spatial.detach(peer);
|
||||
if (peer.audioEl) {
|
||||
try {
|
||||
peer.audioEl.pause();
|
||||
peer.audioEl.srcObject = null;
|
||||
} catch {}
|
||||
peer.audioEl = null;
|
||||
}
|
||||
peer.micStream = null;
|
||||
peer.screenStreams.clear();
|
||||
peer.els = {};
|
||||
this.screenSenders.delete(peer.id);
|
||||
this.ctx.renderScreens();
|
||||
}
|
||||
|
||||
destroyAllPeers(): void {
|
||||
for (const peer of this.ctx.state.peers.values()) this.destroyPeer(peer);
|
||||
this.ctx.state.peers.clear();
|
||||
}
|
||||
|
||||
// --- remote media ----------------------------------------------------------
|
||||
|
||||
onRemoteTrack(peer: PeerSession, ev: RTCTrackEvent): void {
|
||||
const track = ev.track;
|
||||
const stream = ev.streams && ev.streams[0] ? ev.streams[0] : new MediaStream([track]);
|
||||
|
||||
if (track.kind === 'video') {
|
||||
const isNewScreenShare = !peer.screenStreams.has(stream.id);
|
||||
peer.screenStreams.set(stream.id, stream);
|
||||
track.addEventListener('ended', () => {
|
||||
if (peer.screenStreams.get(stream.id) === stream) {
|
||||
peer.screenStreams.delete(stream.id);
|
||||
if (!peer.screenStreams.size) {
|
||||
peer.setSharing(false);
|
||||
captureRumIssue('Remote screen share ended', 'INFO', {
|
||||
peerId: peer.id,
|
||||
peerName: peer.name,
|
||||
streamId: stream.id,
|
||||
});
|
||||
}
|
||||
this.ctx.renderScreens();
|
||||
this.ctx.renderTiles();
|
||||
}
|
||||
});
|
||||
stream.addEventListener(
|
||||
'inactive',
|
||||
() => {
|
||||
if (peer.screenStreams.get(stream.id) === stream) {
|
||||
peer.screenStreams.delete(stream.id);
|
||||
peer.setSharing(false);
|
||||
this.ctx.renderScreens();
|
||||
this.ctx.renderTiles();
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
this.ctx.renderScreens();
|
||||
if (isNewScreenShare) this.ctx.playCue('share');
|
||||
console.log('remote screen share from', peer.name);
|
||||
} else if (track.kind === 'audio') {
|
||||
peer.micStream = stream;
|
||||
|
||||
// Ensure AudioContext is active (resumes on Firefox/Safari if suspended)
|
||||
const audioCtx = this.ctx.state.audioCtx;
|
||||
if (audioCtx && audioCtx.state === 'suspended') {
|
||||
audioCtx.resume().catch(() => {});
|
||||
}
|
||||
|
||||
// Chrome/Safari benefit from having an Audio element attached to pump the stream
|
||||
if (!peer.audioEl) {
|
||||
peer.audioEl = new Audio();
|
||||
peer.audioEl.srcObject = stream;
|
||||
peer.audioEl.muted = true;
|
||||
peer.audioEl.play().catch((e) => console.warn('peer audioEl autoplay', e));
|
||||
}
|
||||
this.ctx.spatial.attach(peer, stream);
|
||||
console.log('remote mic from', peer.name);
|
||||
}
|
||||
this.ctx.renderTiles();
|
||||
}
|
||||
|
||||
// --- screen share senders ---------------------------------------------------
|
||||
|
||||
addScreenToPeer(peer: PeerSession): void {
|
||||
if (!peer.pc || !this.ctx.state.sharing) return;
|
||||
const peerSenders = new Map<string, RTCRtpSender[]>();
|
||||
for (const [streamId, stream] of this.ctx.state.localScreenStreams) {
|
||||
const senders: RTCRtpSender[] = [];
|
||||
for (const track of stream.getTracks()) {
|
||||
senders.push(peer.pc.addTrack(track, stream));
|
||||
}
|
||||
peerSenders.set(streamId, senders);
|
||||
}
|
||||
this.screenSenders.set(peer.id, peerSenders); // renegotiation fires automatically
|
||||
}
|
||||
|
||||
removeScreenTracks(streams: MediaStream[]): void {
|
||||
for (const [peerId, peerSenders] of this.screenSenders) {
|
||||
const peer = this.ctx.state.peers.get(peerId);
|
||||
if (!peer || !peer.pc) continue;
|
||||
for (const localStream of streams) {
|
||||
for (const sender of peerSenders.get(localStream.id) || []) {
|
||||
try {
|
||||
peer.pc.removeTrack(sender);
|
||||
} catch {}
|
||||
}
|
||||
peerSenders.delete(localStream.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearScreenSenders(): void {
|
||||
this.screenSenders.clear();
|
||||
}
|
||||
|
||||
// --- quality monitoring -------------------------------------------------------
|
||||
|
||||
monitorPeerQuality(peer: PeerSession): void {
|
||||
if (peer.qualityTimer) clearInterval(peer.qualityTimer);
|
||||
peer.qualityTimer = setInterval(async () => {
|
||||
const pc = peer.pc;
|
||||
if (!pc || pc.connectionState !== 'connected' || !pc.getStats) return;
|
||||
try {
|
||||
const stats = await pc.getStats();
|
||||
let inbound: any;
|
||||
let outbound: any;
|
||||
let remoteInbound: any;
|
||||
stats.forEach((report: any) => {
|
||||
if (report.kind !== 'audio' && report.mediaType !== 'audio') return;
|
||||
if (report.type === 'inbound-rtp') inbound = report;
|
||||
if (report.type === 'outbound-rtp') outbound = report;
|
||||
if (report.type === 'remote-inbound-rtp') remoteInbound = report;
|
||||
});
|
||||
const packets = (inbound?.packetsReceived || 0) + (inbound?.packetsLost || 0);
|
||||
const lossRatio = packets > 20 ? (inbound.packetsLost || 0) / packets : 0;
|
||||
const jitterMs = (inbound?.jitter || 0) * 1000;
|
||||
const rttMs = (remoteInbound?.roundTripTime || 0) * 1000;
|
||||
const issue = lossRatio >= 0.05 || jitterMs >= 80 || rttMs >= 500;
|
||||
if (issue && (!peer.lastQualityNotice || Date.now() - peer.lastQualityNotice > ICE_NOTICE_INTERVAL)) {
|
||||
peer.lastQualityNotice = Date.now();
|
||||
const details = `quality degraded (loss ${Math.round(lossRatio * 100)}%, jitter ${Math.round(jitterMs)}ms${rttMs ? `, RTT ${Math.round(rttMs)}ms` : ''})`;
|
||||
this.ctx.toast(`${peer.name}: ${details}`, 'warn');
|
||||
captureRumIssue('WebRTC audio quality degraded', 'WARNING', {
|
||||
peerId: peer.id,
|
||||
peerName: peer.name,
|
||||
lossRatio,
|
||||
jitterMs,
|
||||
rttMs,
|
||||
packetsReceived: inbound?.packetsReceived,
|
||||
packetsLost: inbound?.packetsLost,
|
||||
bytesReceived: inbound?.bytesReceived,
|
||||
bytesSent: outbound?.bytesSent,
|
||||
path: peer.icePath?.path,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
captureRumIssue('Could not inspect WebRTC audio quality', 'WARNING', {
|
||||
peerId: peer.id,
|
||||
error: (err as Error)?.message || String(err),
|
||||
});
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
/** Notify (rate-limited) about ICE problems; also mirrors to RUM. */
|
||||
notifyIceIssue(peer: PeerSession | null, message: string, extra: Record<string, unknown> = {}): void {
|
||||
const now = Date.now();
|
||||
const key = `${message}:${extra.url || ''}`;
|
||||
if (!peer || peer.lastIceNotice?.key !== key || now - peer.lastIceNotice.at > ICE_NOTICE_INTERVAL) {
|
||||
this.ctx.toast(`${peer?.name || 'Voice'}: ${message}`, 'warn');
|
||||
if (peer) peer.lastIceNotice = { key, at: now };
|
||||
}
|
||||
captureRumIssue(message, 'WARNING', {
|
||||
peerId: peer?.id,
|
||||
peerName: peer?.name,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
micGainCeilings(): { normal: number; hifi: number } {
|
||||
return { normal: MAX_MIC_GAIN, hifi: MAX_MIC_GAIN_HIFI };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Runtime configuration resolved from the environment.
|
||||
*
|
||||
* The static directory prefers the Vite production build (`dist/`) and falls
|
||||
* back to `public/` so a dev-mode server never 404s outright.
|
||||
*/
|
||||
export class AppConfig {
|
||||
readonly port: number;
|
||||
readonly host: string;
|
||||
readonly staticDir: string;
|
||||
|
||||
constructor(env: Record<string, string | undefined> = process.env, cwd: string = process.cwd()) {
|
||||
this.port = Number(env.PORT || 8080);
|
||||
this.host = env.HOST || '0.0.0.0';
|
||||
const dist = path.join(cwd, 'dist');
|
||||
this.staticDir = fs.existsSync(dist) ? dist : path.join(cwd, 'public');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import http from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { trace } from '@opentelemetry/api';
|
||||
|
||||
import type { AppConfig } from './config';
|
||||
import type { IceConfigProvider } from './ice';
|
||||
import type { RoomRegistry } from './signaling';
|
||||
import type { StaticFileServer } from './static-files';
|
||||
|
||||
function traceId(): string | undefined {
|
||||
return trace.getActiveSpan()?.spanContext().traceId;
|
||||
}
|
||||
|
||||
export interface GibbzHttpServerOptions {
|
||||
config: AppConfig;
|
||||
ice: IceConfigProvider;
|
||||
registry: RoomRegistry;
|
||||
staticFiles: StaticFileServer;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP surface: health probes, /api/ice, /api/rooms and the static client.
|
||||
* WebSocket upgrades are delegated to the signaling server via attach().
|
||||
*/
|
||||
export class GibbzHttpServer {
|
||||
readonly server: http.Server;
|
||||
private readonly options: GibbzHttpServerOptions;
|
||||
|
||||
constructor(options: GibbzHttpServerOptions) {
|
||||
this.options = options;
|
||||
this.server = http.createServer((req, res) => this.handleRequest(req, res));
|
||||
|
||||
// Node's default keepAliveTimeout (5s) is shorter than ingress-nginx's default
|
||||
// upstream keepalive idle timeout (60s). Under that mismatch, nginx can pick a
|
||||
// pooled connection Node has *just* closed and forward a request onto it,
|
||||
// which comes back to the browser as a 502 — invisible from inside this
|
||||
// process because Node never saw the request. headersTimeout must stay above
|
||||
// keepAliveTimeout (Node enforces this).
|
||||
this.server.keepAliveTimeout = 65_000;
|
||||
this.server.headersTimeout = 66_000;
|
||||
|
||||
this.server.on('clientError', (err, socket) => {
|
||||
console.warn(`[http] clientError: ${err.message} (code=${(err as NodeJS.ErrnoException).code})`);
|
||||
if (socket.writable) socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
|
||||
});
|
||||
}
|
||||
|
||||
private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
const start = process.hrtime.bigint();
|
||||
const { method, url } = req;
|
||||
res.on('close', () => {
|
||||
const ms = Number(process.hrtime.bigint() - start) / 1e6;
|
||||
// res.writableEnded false here means the client (or ingress) dropped the
|
||||
// connection before we finished writing — the smoking gun for a 502 that
|
||||
// originates on the proxy side rather than in this process.
|
||||
if (!res.writableEnded) {
|
||||
console.warn(`[http] ABORTED ${method} ${url} after ${ms.toFixed(1)}ms (status=${res.statusCode}) trace=${traceId()}`);
|
||||
} else if (ms > 1000) {
|
||||
console.warn(`[http] SLOW ${method} ${url} ${res.statusCode} ${ms.toFixed(1)}ms trace=${traceId()}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (url === '/healthz' || url === '/readyz' || url === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('ok');
|
||||
return;
|
||||
}
|
||||
if (url?.startsWith('/api/ice')) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(this.options.ice.get()));
|
||||
return;
|
||||
}
|
||||
if (url?.startsWith('/api/rooms')) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' });
|
||||
res.end(JSON.stringify(this.options.registry.snapshot()));
|
||||
return;
|
||||
}
|
||||
this.options.staticFiles.handle(req, res);
|
||||
}
|
||||
|
||||
start(port = this.options.config.port, host = this.options.config.host): Promise<http.Server> {
|
||||
return new Promise((resolve) => {
|
||||
this.server.listen(port, host, () => resolve(this.server));
|
||||
});
|
||||
}
|
||||
|
||||
stop(): Promise<void> {
|
||||
return new Promise((resolve) => this.server.close(() => resolve()));
|
||||
}
|
||||
|
||||
address(): AddressInfo | null {
|
||||
return this.server.address() as AddressInfo | null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
export interface RtcIceServer {
|
||||
urls: string | string[];
|
||||
username?: string;
|
||||
credential?: string;
|
||||
}
|
||||
|
||||
export interface RtcIceConfig {
|
||||
iceServers: RtcIceServer[];
|
||||
iceTransportPolicy?: 'all' | 'relay' | 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the ICE configuration handed to browsers via /api/ice.
|
||||
*
|
||||
* Env-driven, full JSON override wins:
|
||||
* STUN_URLS comma-separated stun: urls
|
||||
* TURN_URLS comma-separated turn:/turns: urls
|
||||
* TURN_USERNAME / TURN_CREDENTIAL static creds for all TURN urls
|
||||
* ICE_TRANSPORT_POLICY optional WebRTC policy (all, relay, or none)
|
||||
* TURN_LAN_HOST / TURN_LAN_SECRET optional second, LAN-only TURN relay
|
||||
* (own coturn instance + secret, reachable only from
|
||||
* the local network via its own LoadBalancer IP with
|
||||
* no NAT/hairpin involved). Harmless to hand out to
|
||||
* every client: anyone not on that network simply
|
||||
* can't reach the address and ICE ignores it.
|
||||
* ICE_SERVERS_JSON complete {"iceServers":[...]} override
|
||||
*/
|
||||
export class IceConfigProvider {
|
||||
private readonly env: Record<string, string | undefined>;
|
||||
|
||||
constructor(env: Record<string, string | undefined> = process.env) {
|
||||
this.env = env;
|
||||
}
|
||||
|
||||
get(): RtcIceConfig {
|
||||
const jsonOverride = this.env.ICE_SERVERS_JSON;
|
||||
if (jsonOverride) {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonOverride) as RtcIceConfig;
|
||||
if (Array.isArray(parsed.iceServers)) return parsed;
|
||||
console.error('ICE_SERVERS_JSON missing iceServers array, falling back to env vars');
|
||||
} catch (err) {
|
||||
console.error('ICE_SERVERS_JSON invalid JSON:', (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
const urls = (s: string | undefined): string[] =>
|
||||
(s || '').split(',').map((x) => x.trim()).filter(Boolean);
|
||||
|
||||
const stunUrls = urls(this.env.STUN_URLS || 'stun:turn.k6n.net:3478,stun:stun.l.google.com:19302');
|
||||
const iceServers: RtcIceServer[] = stunUrls.map((u) => ({ urls: u }));
|
||||
|
||||
const turnUrls = urls(
|
||||
this.env.TURN_URLS ||
|
||||
'turn:turn.k6n.net:3478?transport=udp,turn:turn.k6n.net:3478?transport=tcp,turns:turn.k6n.net:5349?transport=tcp',
|
||||
);
|
||||
if (turnUrls.length) {
|
||||
let username = this.env.TURN_USERNAME || '';
|
||||
let credential = this.env.TURN_CREDENTIAL || this.env.TURN_PASSWORD || '';
|
||||
|
||||
// If a TURN secret is provided (coturn use-auth-secret / static-auth-secret),
|
||||
// generate ephemeral HMAC credentials.
|
||||
const turnSecret = this.env.TURN_SECRET || this.env.TURN_STATIC_AUTH_SECRET || '';
|
||||
const ttl = Number(this.env.TURN_TTL || 86400);
|
||||
if (turnSecret) {
|
||||
({ username, credential } = hmacTurnCredential(turnSecret, 'gibbz', ttl));
|
||||
}
|
||||
|
||||
iceServers.push({ urls: turnUrls, username, credential });
|
||||
|
||||
if (this.env.TURN_LAN_HOST && this.env.TURN_LAN_SECRET) {
|
||||
const { username: lanUsername, credential: lanCredential } = hmacTurnCredential(
|
||||
this.env.TURN_LAN_SECRET,
|
||||
'gibbz-lan',
|
||||
ttl,
|
||||
);
|
||||
iceServers.push({
|
||||
urls: [
|
||||
`turn:${this.env.TURN_LAN_HOST}:3478?transport=udp`,
|
||||
`turn:${this.env.TURN_LAN_HOST}:3478?transport=tcp`,
|
||||
],
|
||||
username: lanUsername,
|
||||
credential: lanCredential,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const config: RtcIceConfig = { iceServers };
|
||||
const iceTransportPolicy = this.env.ICE_TRANSPORT_POLICY;
|
||||
if (iceTransportPolicy && ['all', 'relay', 'none'].includes(iceTransportPolicy)) {
|
||||
config.iceTransportPolicy = iceTransportPolicy as RtcIceConfig['iceTransportPolicy'];
|
||||
}
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
/** coturn static-auth-secret style ephemeral credential (REST API scheme). */
|
||||
export function hmacTurnCredential(secret: string, label: string, ttl: number) {
|
||||
const expiry = Math.floor(Date.now() / 1000) + ttl;
|
||||
const username = `${expiry}:${label}`;
|
||||
const hmac = crypto.createHmac('sha1', secret);
|
||||
hmac.update(username);
|
||||
return { username, credential: hmac.digest('base64') };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||
|
||||
import { GibbzHttpServer } from './http-server.ts';
|
||||
import { IceConfigProvider } from './ice.ts';
|
||||
import { RoomRegistry, SignalingServer } from './signaling.ts';
|
||||
import { StaticFileServer } from './static-files.ts';
|
||||
import { AppConfig } from './config.ts';
|
||||
import { Tracing } from './tracing.ts';
|
||||
|
||||
export interface GibbzApp {
|
||||
config: AppConfig;
|
||||
ice: IceConfigProvider;
|
||||
registry: RoomRegistry;
|
||||
staticFiles: StaticFileServer;
|
||||
signaling: SignalingServer;
|
||||
http: GibbzHttpServer;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
export function createApp(config: AppConfig = new AppConfig()): GibbzApp {
|
||||
const ice = new IceConfigProvider();
|
||||
const registry = new RoomRegistry();
|
||||
const staticFiles = new StaticFileServer(config.staticDir);
|
||||
const signaling = new SignalingServer(registry);
|
||||
const http = new GibbzHttpServer({ config, ice, registry, staticFiles });
|
||||
signaling.attach(http.server);
|
||||
|
||||
return {
|
||||
config,
|
||||
ice,
|
||||
registry,
|
||||
staticFiles,
|
||||
signaling,
|
||||
http,
|
||||
async start(): Promise<void> {
|
||||
await http.start(config.port, config.host);
|
||||
const iceConfig = ice.get();
|
||||
console.log(`gibbz listening on http://${config.host}:${config.port} (static: ${config.staticDir})`);
|
||||
console.log(`ICE servers: ${JSON.stringify(iceConfig.iceServers)}`);
|
||||
},
|
||||
async stop(): Promise<void> {
|
||||
signaling.stop();
|
||||
await http.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
new Tracing().start();
|
||||
const app = createApp();
|
||||
app.start().catch((err) => {
|
||||
console.error('failed to start server', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
// `import.meta.main` is Bun's entrypoint flag; the pathToFileURL comparison is
|
||||
// the node equivalent so the server can also run under `node src/server/index.ts`.
|
||||
const invokedDirectly =
|
||||
(import.meta as { main?: boolean }).main === true ||
|
||||
(process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href);
|
||||
if (invokedDirectly) main();
|
||||
@@ -0,0 +1,269 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import type { IncomingMessage, Server as HttpServer } from 'node:http';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signaling protocol
|
||||
// client -> server: {type: offer|answer|candidate, to} {type: pos|state}
|
||||
// server -> client: {type: hello, id, peers} {type: joined|left, ...}
|
||||
// relayed messages get "from" stamped
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PeerState {
|
||||
muted: boolean;
|
||||
deafened: boolean;
|
||||
sharing: boolean;
|
||||
}
|
||||
|
||||
export interface PeerSnapshot {
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
state: PeerState;
|
||||
}
|
||||
|
||||
interface ClientMessageBase {
|
||||
type: string;
|
||||
to?: unknown;
|
||||
x?: unknown;
|
||||
y?: unknown;
|
||||
state?: Partial<PeerState>;
|
||||
sdp?: unknown;
|
||||
candidate?: unknown;
|
||||
name?: unknown;
|
||||
}
|
||||
|
||||
export class Peer {
|
||||
readonly socket: WebSocket;
|
||||
readonly id: string;
|
||||
name: string;
|
||||
x = 50;
|
||||
y = 50;
|
||||
state: PeerState = { muted: false, deafened: false, sharing: false };
|
||||
|
||||
constructor(socket: WebSocket, id: string, name: string) {
|
||||
this.socket = socket;
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
get connected(): boolean {
|
||||
return this.socket.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
send(payload: unknown): void {
|
||||
if (this.connected) this.socket.send(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
snapshot(): PeerSnapshot {
|
||||
return { id: this.id, name: this.name, x: this.x, y: this.y, state: this.state };
|
||||
}
|
||||
}
|
||||
|
||||
export class Room {
|
||||
readonly id: string;
|
||||
private readonly peers = new Map<string, Peer>();
|
||||
|
||||
constructor(id: string) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.peers.size;
|
||||
}
|
||||
|
||||
values(): IterableIterator<Peer> {
|
||||
return this.peers.values();
|
||||
}
|
||||
|
||||
get(id: string): Peer | undefined {
|
||||
return this.peers.get(id);
|
||||
}
|
||||
|
||||
has(id: string): boolean {
|
||||
return this.peers.has(id);
|
||||
}
|
||||
|
||||
add(peer: Peer): void {
|
||||
this.peers.set(peer.id, peer);
|
||||
}
|
||||
|
||||
delete(id: string): boolean {
|
||||
return this.peers.delete(id);
|
||||
}
|
||||
|
||||
broadcast(msg: unknown, exceptId?: string): void {
|
||||
for (const [id, peer] of this.peers) {
|
||||
if (id !== exceptId && peer.connected) peer.socket.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
snapshot(): PeerSnapshot[] {
|
||||
return [...this.peers.values()].map((p) => p.snapshot());
|
||||
}
|
||||
}
|
||||
|
||||
export class RoomRegistry {
|
||||
private readonly rooms = new Map<string, Room>();
|
||||
|
||||
room(roomId: string): Room {
|
||||
let room = this.rooms.get(roomId);
|
||||
if (!room) {
|
||||
room = new Room(roomId);
|
||||
this.rooms.set(roomId, room);
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
dropIfEmpty(roomId: string): void {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (room && room.size === 0) this.rooms.delete(roomId);
|
||||
}
|
||||
|
||||
allRooms(): IterableIterator<Room> {
|
||||
return this.rooms.values();
|
||||
}
|
||||
|
||||
allPeers(): Peer[] {
|
||||
const out: Peer[] = [];
|
||||
for (const room of this.rooms.values()) out.push(...room.values());
|
||||
return out;
|
||||
}
|
||||
|
||||
snapshot(): Record<string, PeerSnapshot[]> {
|
||||
const out: Record<string, PeerSnapshot[]> = {};
|
||||
for (const [roomId, room] of this.rooms) out[roomId] = room.snapshot();
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/** WebSocket signaling: room rosters, position/state fan-out and WebRTC relay. */
|
||||
export class SignalingServer {
|
||||
readonly wss: WebSocketServer;
|
||||
private readonly registry: RoomRegistry;
|
||||
private heartbeat: ReturnType<typeof setInterval>;
|
||||
|
||||
constructor(registry: RoomRegistry) {
|
||||
this.registry = registry;
|
||||
this.wss = new WebSocketServer({ noServer: true });
|
||||
// heartbeat: drop dead sockets
|
||||
this.heartbeat = setInterval(() => this.pingAll(), 30_000);
|
||||
this.wss.on('close', () => clearInterval(this.heartbeat));
|
||||
this.wss.on('connection', (socket, req) => this.handleConnection(socket, req));
|
||||
}
|
||||
|
||||
/** Routes /ws upgrades from the HTTP server into the signaling socket. */
|
||||
attach(server: HttpServer): void {
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
const { pathname } = new URL(req.url || '/', 'http://x');
|
||||
if (pathname === '/ws') {
|
||||
this.wss.handleUpgrade(req, socket, head, (ws) => this.wss.emit('connection', ws, req));
|
||||
} else {
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
clearInterval(this.heartbeat);
|
||||
}
|
||||
|
||||
private pingAll(): void {
|
||||
for (const peer of this.registry.allPeers()) {
|
||||
if (peer.connected) peer.socket.ping();
|
||||
}
|
||||
}
|
||||
|
||||
broadcastRosters(): void {
|
||||
const data = JSON.stringify({ type: 'rosters', rooms: this.registry.snapshot() });
|
||||
for (const client of this.wss.clients) {
|
||||
if (client.readyState === WebSocket.OPEN) client.send(data);
|
||||
}
|
||||
}
|
||||
|
||||
private handleConnection(socket: WebSocket, req: IncomingMessage): void {
|
||||
const q = new URL(req.url || '/', 'http://x').searchParams;
|
||||
const roomId = (q.get('room') || 'general').slice(0, 64);
|
||||
const name = (q.get('name') || 'anon').slice(0, 32);
|
||||
const id = crypto.randomBytes(4).toString('hex');
|
||||
const room = this.registry.room(roomId);
|
||||
const client = new Peer(socket, id, name);
|
||||
room.add(client);
|
||||
|
||||
client.send({
|
||||
type: 'hello',
|
||||
id,
|
||||
room: roomId,
|
||||
peers: [...room.values()].filter((p) => p.id !== id).map((p) => p.snapshot()),
|
||||
});
|
||||
room.broadcast({ type: 'joined', peer: client.snapshot() }, id);
|
||||
this.broadcastRosters();
|
||||
console.log(`[signaling] ${name} (${id}) joined #${roomId} — ${room.size} in room`);
|
||||
|
||||
socket.on('message', (raw) => {
|
||||
const text = Array.isArray(raw) ? Buffer.concat(raw).toString() : raw.toString();
|
||||
let msg: ClientMessageBase;
|
||||
try {
|
||||
msg = JSON.parse(text) as ClientMessageBase;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!msg || typeof msg.type !== 'string') return;
|
||||
this.handleMessage(room, client, msg);
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
room.delete(id);
|
||||
this.registry.dropIfEmpty(roomId);
|
||||
if (room.size > 0) room.broadcast({ type: 'left', id });
|
||||
this.broadcastRosters();
|
||||
console.log(`[signaling] ${name} (${id}) left #${roomId} — ${room.size} in room`);
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessage(room: Room, client: Peer, msg: ClientMessageBase): void {
|
||||
const send = (payload: unknown): void => client.send(payload);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'ping': {
|
||||
send({ type: 'pong' });
|
||||
break;
|
||||
}
|
||||
case 'pos': {
|
||||
const x = Number(msg.x);
|
||||
const y = Number(msg.y);
|
||||
if (Number.isFinite(x) && Number.isFinite(y)) {
|
||||
client.x = Math.min(100, Math.max(0, x));
|
||||
client.y = Math.min(100, Math.max(0, y));
|
||||
room.broadcast({ type: 'pos', from: client.id, x: client.x, y: client.y }, client.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'state': {
|
||||
client.state = {
|
||||
muted: !!msg.state?.muted,
|
||||
deafened: !!msg.state?.deafened,
|
||||
sharing: !!msg.state?.sharing,
|
||||
};
|
||||
room.broadcast({ type: 'state', from: client.id, state: client.state }, client.id);
|
||||
this.broadcastRosters();
|
||||
break;
|
||||
}
|
||||
case 'offer':
|
||||
case 'answer':
|
||||
case 'candidate':
|
||||
case 'reset': {
|
||||
if (typeof msg.to !== 'string' || !room.has(msg.to)) return;
|
||||
const target = room.get(msg.to);
|
||||
if (target && target.connected) {
|
||||
const { to: _to, ...rest } = msg;
|
||||
target.socket.send(JSON.stringify({ ...rest, from: client.id }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break; // ignore unknown types — forward compat
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json',
|
||||
'.webmanifest': 'application/manifest+json',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.ico': 'image/x-icon',
|
||||
};
|
||||
|
||||
/** Serves files from a single root with a traversal guard. */
|
||||
export class StaticFileServer {
|
||||
private readonly rootDir: string;
|
||||
|
||||
constructor(rootDir: string) {
|
||||
this.rootDir = rootDir;
|
||||
}
|
||||
|
||||
/** Absolute path for a URL path, or null when it escapes the root. */
|
||||
resolve(urlPath: string): string {
|
||||
const rel = urlPath === '/' ? '/index.html' : urlPath;
|
||||
const file = path.normalize(path.join(this.rootDir, rel));
|
||||
if (!file.startsWith(this.rootDir)) return '';
|
||||
return file;
|
||||
}
|
||||
|
||||
handle(req: IncomingMessage, res: ServerResponse): void {
|
||||
let urlPath: string;
|
||||
try {
|
||||
urlPath = decodeURIComponent(new URL(req.url || '/', 'http://x').pathname);
|
||||
} catch {
|
||||
res.writeHead(400).end('bad request');
|
||||
return;
|
||||
}
|
||||
const file = this.resolve(urlPath);
|
||||
if (!file) {
|
||||
res.writeHead(403).end('forbidden');
|
||||
return;
|
||||
}
|
||||
fs.readFile(file, (err, data) => {
|
||||
if (err) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' }).end('not found');
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'Content-Type': MIME[path.extname(file)] || 'application/octet-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
});
|
||||
res.end(data);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// OpenTelemetry tracing and logging initialization for the gibbz backend.
|
||||
// Loaded before HTTP / network modules start serving traffic.
|
||||
import process from 'node:process';
|
||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
||||
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
|
||||
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
||||
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http';
|
||||
import { BatchLogRecordProcessor } from '@opentelemetry/sdk-logs';
|
||||
import { resourceFromAttributes } from '@opentelemetry/resources';
|
||||
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
|
||||
|
||||
export class Tracing {
|
||||
private sdk: NodeSDK | undefined;
|
||||
|
||||
start(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
onFatal?: (err: unknown) => void,
|
||||
): void {
|
||||
const serviceName = env.OTEL_SERVICE_NAME || 'gibbz';
|
||||
const serviceRelease = env.SERVICE_RELEASE || env.OTEL_SERVICE_RELEASE || env.APP_RELEASE || 'dev';
|
||||
const environment = env.ENVIRONMENT || env.NODE_ENV || 'production';
|
||||
const otelUrl = (env.OTEL_EXPORTER_OTLP_ENDPOINT || env.OTEL_URL || 'https://otel.k6n.net').replace(/\/+$/, '');
|
||||
const ingestKey = env.OTEL_INGEST_KEY || '';
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (ingestKey) headers['X-Otel-Ingest-Key'] = ingestKey;
|
||||
|
||||
const resource = resourceFromAttributes({
|
||||
[ATTR_SERVICE_NAME]: serviceName,
|
||||
[ATTR_SERVICE_VERSION]: serviceRelease,
|
||||
'service.release': serviceRelease,
|
||||
'service.environment': environment,
|
||||
});
|
||||
|
||||
const sdk = new NodeSDK({
|
||||
resource,
|
||||
traceExporter: new OTLPTraceExporter({ url: `${otelUrl}/v1/traces`, headers }),
|
||||
logRecordProcessor: new BatchLogRecordProcessor({
|
||||
exporter: new OTLPLogExporter({ url: `${otelUrl}/v1/logs`, headers }),
|
||||
}),
|
||||
instrumentations: [
|
||||
getNodeAutoInstrumentations({
|
||||
'@opentelemetry/instrumentation-fs': { enabled: false },
|
||||
}),
|
||||
],
|
||||
});
|
||||
this.sdk = sdk;
|
||||
|
||||
try {
|
||||
sdk.start();
|
||||
console.log(
|
||||
`[otel] Initialized OpenTelemetry for ${serviceName} (release: ${serviceRelease}, env: ${environment}) -> ${otelUrl}`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[otel] Failed to start OpenTelemetry SDK:', err);
|
||||
onFatal?.(err);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
this.shutdown()
|
||||
.then(() => {
|
||||
console.log('[otel] SDK shut down successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[otel] Error shutting down SDK', err);
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
await this.sdk?.shutdown();
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const http = require('node:http');
|
||||
|
||||
test('server module syntax and exports', () => {
|
||||
assert.ok(true);
|
||||
});
|
||||
|
||||
test('static files exist', () => {
|
||||
const fs = require('fs');
|
||||
assert.ok(fs.existsSync('./public/index.html'), 'index.html exists');
|
||||
assert.ok(fs.existsSync('./public/app.js'), 'app.js exists');
|
||||
assert.ok(fs.existsSync('./public/style.css'), 'style.css exists');
|
||||
});
|
||||
|
||||
test('server HTTP and WebSocket signaling endpoints', async (t) => {
|
||||
const { server, wss, interval, startServer } = require('./server');
|
||||
await startServer(0, '127.0.0.1');
|
||||
const port = server.address().port;
|
||||
|
||||
t.after(() => {
|
||||
clearInterval(interval);
|
||||
wss.close();
|
||||
server.close();
|
||||
});
|
||||
|
||||
// test /api/ice endpoint
|
||||
const resIce = await fetch(`http://127.0.0.1:${port}/api/ice`);
|
||||
assert.strictEqual(resIce.status, 200);
|
||||
const ice = await resIce.json();
|
||||
assert.ok(Array.isArray(ice.iceServers), 'iceServers array returned');
|
||||
const stunUrls = ice.iceServers.flatMap((s) => (Array.isArray(s.urls) ? s.urls : [s.urls]));
|
||||
assert.ok(stunUrls.some((u) => u.includes('turn.k6n.net')), 'cluster stun/turn included');
|
||||
|
||||
// test /healthz
|
||||
const resHealth = await fetch(`http://127.0.0.1:${port}/healthz`);
|
||||
assert.strictEqual(resHealth.status, 200);
|
||||
const healthText = await resHealth.text();
|
||||
assert.strictEqual(healthText, 'ok');
|
||||
|
||||
// test /api/rooms initially empty
|
||||
const resRoomsInit = await fetch(`http://127.0.0.1:${port}/api/rooms`);
|
||||
assert.strictEqual(resRoomsInit.status, 200);
|
||||
const roomsInit = await resRoomsInit.json();
|
||||
assert.deepStrictEqual(roomsInit, {});
|
||||
|
||||
// test WebSocket signaling /ws
|
||||
const { WebSocket } = require('ws');
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws?room=testroom&name=tester`);
|
||||
|
||||
const messages = [];
|
||||
const helloPromise = new Promise((resolve, reject) => {
|
||||
ws.on('message', (raw) => {
|
||||
try {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
messages.push(msg);
|
||||
if (msg.type === 'hello') resolve(msg);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
ws.on('error', reject);
|
||||
});
|
||||
|
||||
const msg = await helloPromise;
|
||||
assert.strictEqual(msg.type, 'hello');
|
||||
assert.strictEqual(msg.room, 'testroom');
|
||||
assert.ok(msg.id, 'client id assigned');
|
||||
|
||||
// test /api/rooms while tester is connected
|
||||
const resRoomsActive = await fetch(`http://127.0.0.1:${port}/api/rooms`);
|
||||
assert.strictEqual(resRoomsActive.status, 200);
|
||||
const roomsActive = await resRoomsActive.json();
|
||||
assert.ok(roomsActive.testroom, 'testroom present in /api/rooms');
|
||||
assert.strictEqual(roomsActive.testroom.length, 1);
|
||||
assert.strictEqual(roomsActive.testroom[0].name, 'tester');
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test('TURN credentials generation with TURN_SECRET', () => {
|
||||
const { iceConfig } = require('./server');
|
||||
process.env.TURN_SECRET = 'secret123';
|
||||
try {
|
||||
const config = iceConfig();
|
||||
const turnServer = config.iceServers.find((s) => s.urls && (Array.isArray(s.urls) ? s.urls : [s.urls]).some((u) => u.startsWith('turn')));
|
||||
assert.ok(turnServer, 'turnServer configured');
|
||||
assert.ok(turnServer.username.endsWith(':gibbz'), 'ephemeral username generated');
|
||||
assert.ok(turnServer.credential.length > 0, 'HMAC credential generated');
|
||||
} finally {
|
||||
delete process.env.TURN_SECRET;
|
||||
}
|
||||
});
|
||||
|
||||
test('ICE transport policy is passed through to browser configuration', () => {
|
||||
const { iceConfig } = require('./server');
|
||||
process.env.ICE_TRANSPORT_POLICY = 'relay';
|
||||
try {
|
||||
assert.strictEqual(iceConfig().iceTransportPolicy, 'relay');
|
||||
} finally {
|
||||
delete process.env.ICE_TRANSPORT_POLICY;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import fs from 'node:fs';
|
||||
import { afterAll, describe, expect, test } from 'vitest';
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
import { createApp, type GibbzApp } from '../src/server/index.ts';
|
||||
import { IceConfigProvider } from '../src/server/ice.ts';
|
||||
|
||||
let app: GibbzApp | undefined;
|
||||
let port = 0;
|
||||
|
||||
async function startTestApp(): Promise<number> {
|
||||
if (!app) {
|
||||
app = createApp();
|
||||
await app.http.start(0, '127.0.0.1');
|
||||
port = app.http.address()!.port;
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.stop();
|
||||
});
|
||||
|
||||
describe('server', () => {
|
||||
test('static client files exist', () => {
|
||||
expect(fs.existsSync('src/client/index.html'), 'index.html exists').toBe(true);
|
||||
expect(fs.existsSync('src/client/main.ts'), 'main.ts exists').toBe(true);
|
||||
expect(fs.existsSync('src/client/style.css'), 'style.css exists').toBe(true);
|
||||
});
|
||||
|
||||
test('/healthz answers ok', async () => {
|
||||
const p = await startTestApp();
|
||||
const res = await fetch(`http://127.0.0.1:${p}/healthz`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe('ok');
|
||||
});
|
||||
|
||||
test('/api/ice returns cluster stun/turn', async () => {
|
||||
const p = await startTestApp();
|
||||
const res = await fetch(`http://127.0.0.1:${p}/api/ice`);
|
||||
expect(res.status).toBe(200);
|
||||
const ice = await res.json();
|
||||
expect(Array.isArray(ice.iceServers)).toBe(true);
|
||||
const stunUrls = ice.iceServers.flatMap((s: { urls: string | string[] }) =>
|
||||
Array.isArray(s.urls) ? s.urls : [s.urls],
|
||||
);
|
||||
expect(stunUrls.some((u: string) => u.includes('turn.k6n.net'))).toBe(true);
|
||||
});
|
||||
|
||||
test('/api/rooms starts empty and reflects a connected peer', async () => {
|
||||
const p = await startTestApp();
|
||||
const empty = await (await fetch(`http://127.0.0.1:${p}/api/rooms`)).json();
|
||||
expect(empty).toEqual({});
|
||||
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${p}/ws?room=testroom&name=tester`);
|
||||
const hello = await new Promise<{ id: string; room: string; type: string }>((resolve, reject) => {
|
||||
ws.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.type === 'hello') resolve(msg);
|
||||
});
|
||||
ws.on('error', reject);
|
||||
});
|
||||
expect(hello.type).toBe('hello');
|
||||
expect(hello.room).toBe('testroom');
|
||||
expect(hello.id).toBeTruthy();
|
||||
|
||||
const res = await fetch(`http://127.0.0.1:${p}/api/rooms`);
|
||||
const rooms = await res.json();
|
||||
expect(rooms.testroom).toBeTruthy();
|
||||
expect(rooms.testroom).toHaveLength(1);
|
||||
expect(rooms.testroom[0].name).toBe('tester');
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test('TURN secret generates ephemeral HMAC credentials', () => {
|
||||
const provider = new IceConfigProvider({ TURN_SECRET: 'secret123' });
|
||||
const config = provider.get();
|
||||
const turnServer = config.iceServers.find((s) =>
|
||||
(Array.isArray(s.urls) ? s.urls : [s.urls]).some((u) => u.startsWith('turn')),
|
||||
);
|
||||
expect(turnServer).toBeTruthy();
|
||||
expect(turnServer!.username!.endsWith(':gibbz')).toBe(true);
|
||||
expect(turnServer!.credential!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('ICE transport policy is passed through', () => {
|
||||
const provider = new IceConfigProvider({ ICE_TRANSPORT_POLICY: 'relay' });
|
||||
expect(provider.get().iceTransportPolicy).toBe('relay');
|
||||
});
|
||||
});
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
// OpenTelemetry tracing and logging initialization for gibbz backend
|
||||
'use strict';
|
||||
|
||||
const process = require('process');
|
||||
const { NodeSDK } = require('@opentelemetry/sdk-node');
|
||||
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
|
||||
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
|
||||
const { OTLPLogExporter } = require('@opentelemetry/exporter-logs-otlp-http');
|
||||
const { BatchLogRecordProcessor } = require('@opentelemetry/sdk-logs');
|
||||
const { resourceFromAttributes } = require('@opentelemetry/resources');
|
||||
const { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } = require('@opentelemetry/semantic-conventions');
|
||||
|
||||
const serviceName = process.env.OTEL_SERVICE_NAME || 'gibbz';
|
||||
const serviceRelease = process.env.SERVICE_RELEASE || process.env.OTEL_SERVICE_RELEASE || process.env.APP_RELEASE || 'dev';
|
||||
const environment = process.env.ENVIRONMENT || process.env.NODE_ENV || 'production';
|
||||
const otelUrl = (process.env.OTEL_EXPORTER_OTLP_ENDPOINT || process.env.OTEL_URL || 'https://otel.k6n.net').replace(/\/+$/, '');
|
||||
const ingestKey = process.env.OTEL_INGEST_KEY || '';
|
||||
|
||||
const headers = {};
|
||||
if (ingestKey) {
|
||||
headers['X-Otel-Ingest-Key'] = ingestKey;
|
||||
}
|
||||
|
||||
const resource = resourceFromAttributes({
|
||||
[ATTR_SERVICE_NAME]: serviceName,
|
||||
[ATTR_SERVICE_VERSION]: serviceRelease,
|
||||
'service.name': serviceName,
|
||||
'service.release': serviceRelease,
|
||||
'service.environment': environment,
|
||||
});
|
||||
|
||||
// Configure trace exporter
|
||||
const traceExporter = new OTLPTraceExporter({
|
||||
url: `${otelUrl}/v1/traces`,
|
||||
headers,
|
||||
});
|
||||
|
||||
// Configure logs exporter
|
||||
const logExporter = new OTLPLogExporter({
|
||||
url: `${otelUrl}/v1/logs`,
|
||||
headers,
|
||||
});
|
||||
|
||||
const logRecordProcessor = new BatchLogRecordProcessor(logExporter);
|
||||
|
||||
const sdk = new NodeSDK({
|
||||
resource,
|
||||
traceExporter,
|
||||
logRecordProcessor,
|
||||
instrumentations: [
|
||||
getNodeAutoInstrumentations({
|
||||
'@opentelemetry/instrumentation-fs': { enabled: false },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
sdk.start();
|
||||
console.log(`[otel] Initialized OpenTelemetry for ${serviceName} (release: ${serviceRelease}, env: ${environment}) -> ${otelUrl}`);
|
||||
} catch (err) {
|
||||
console.error('[otel] Failed to start OpenTelemetry SDK:', err);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
sdk.shutdown()
|
||||
.then(() => console.log('[otel] SDK shut down successfully'))
|
||||
.catch((err) => console.error('[otel] Error shutting down SDK', err))
|
||||
.finally(() => process.exit(0));
|
||||
});
|
||||
|
||||
module.exports = { sdk };
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["@types/bun"],
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"useDefineForClassFields": true
|
||||
},
|
||||
"include": ["src", "tests", "vite.config.ts"]
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { nodePolyfills } from 'vite-plugin-node-polyfills';
|
||||
import path from 'path';
|
||||
import path from 'node:path';
|
||||
|
||||
// The client lives in src/client (index.html is the Vite root); static PWA
|
||||
// assets stay in public/static and are served/copied verbatim from the root.
|
||||
export default defineConfig({
|
||||
root: 'public',
|
||||
publicDir: 'static',
|
||||
root: path.resolve(import.meta.dirname, 'src/client'),
|
||||
publicDir: path.resolve(import.meta.dirname, 'public/static'),
|
||||
plugins: [
|
||||
nodePolyfills({
|
||||
include: ['buffer', 'crypto', 'events', 'stream', 'util'],
|
||||
@@ -16,12 +18,12 @@ export default defineConfig({
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: '../dist',
|
||||
outDir: path.resolve(import.meta.dirname, 'dist'),
|
||||
emptyOutDir: true,
|
||||
target: 'esnext',
|
||||
minify: 'esbuild',
|
||||
rollupOptions: {
|
||||
input: path.resolve(import.meta.dirname, 'public/index.html'),
|
||||
input: path.resolve(import.meta.dirname, 'src/client/index.html'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import path from 'node:path';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
},
|
||||
esbuild: {
|
||||
target: 'esnext',
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@server': path.resolve(import.meta.dirname, 'src/server'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user