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