Compare commits

..
1 Commits
Author SHA1 Message Date
developer 2b950e4953 refactor: port backend and frontend to TypeScript classes (bun + vite)
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 14s
eventsourced-ai live check live check
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.
2026-09-24 07:43:04 +00:00
48 changed files with 4589 additions and 9200 deletions

No files matched your search

+18 -11
View File
@@ -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"]
+7 -5
View File
@@ -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 \
+55 -34
View File
@@ -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.
-6036
View File
File diff suppressed because it is too large. Load diff
+13 -3
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
-331
View File
@@ -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 };
+17
View File
@@ -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[]>;
}
}
+557
View File
@@ -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),
};
}
+281
View File
@@ -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);
}
}
+43
View File
@@ -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';
+67
View File
@@ -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;
}
+1 -2
View File
@@ -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', () => {
+9
View File
@@ -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();
}
}
+89
View File
@@ -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');
}
}
+97
View File
@@ -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');
}
}
+172
View File
@@ -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);
}
}
+41
View File
@@ -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 {};
}
+39
View File
@@ -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.
+72
View File
@@ -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;
}
+83
View File
@@ -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);
}
+155
View File
@@ -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);
}
}
+225
View File
@@ -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];
}
+275
View File
@@ -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">&#9679;</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 = '&#9679;';
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();
}
}
}
+125
View File
@@ -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');
}
}
+317
View File
@@ -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);
}
}
}
+54
View File
@@ -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);
}
}
+54
View File
@@ -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();
}
}
+15
View File
@@ -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;
}
+51
View File
@@ -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 };
}
}
+6
View File
@@ -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;
+176
View File
@@ -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');
}
}
+573
View File
@@ -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 };
}
}
+21
View File
@@ -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');
}
}
+94
View File
@@ -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;
}
}
+106
View File
@@ -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') };
}
+64
View File
@@ -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();
+269
View File
@@ -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
}
}
}
+59
View File
@@ -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);
});
}
}
+75
View File
@@ -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();
}
}
-103
View File
@@ -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;
}
});
+90
View File
@@ -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
View File
@@ -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 };
+21
View File
@@ -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"]
}
+7 -5
View File
@@ -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'),
},
},
});
+17
View File
@@ -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'),
},
},
});