Merge branch 'main' of git.k6n.net:mats/gibbz
Build and Publish / Metadata (push) Successful in 1s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 2m59s

This commit is contained in:
mats committed 2026-09-23 09:55:13 +02:00
commit ee9cffc66d
8 files changed
+399 -53

No files matched your search

+13
View File
@@ -20,6 +20,12 @@ PORT=8081 npm start
- 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.
- Voice capture uses mono 48 kHz Opus-oriented settings, a 64 kbps audio ceiling,
and periodic packet-loss, jitter, and RTT quality diagnostics.
- Audio input has manual gain control and a live signal/clipping meter in the
settings dialog; microphone processing is split into `public/audio-manager.js`.
- Screen sharing uses the browser's default codec negotiation, with selectable
`detail` (text/UI) or `motion` (gameplay) content hints.
## ICE configuration (server side) ## ICE configuration (server side)
@@ -28,6 +34,7 @@ PORT=8081 npm start
| `STUN_URLS` | comma-separated `stun:` urls (Google defaults) | | `STUN_URLS` | comma-separated `stun:` urls (Google defaults) |
| `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 |
| `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 8081 / 0.0.0.0) |
@@ -38,11 +45,17 @@ TURN_URLS='turn:turn.example.com:3478?transport=udp,turns:turn.example.com:5349'
TURN_USERNAME=mats TURN_CREDENTIAL=secret npm start 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, no reconnection, rooms are in-memory, signaling is unencrypted ws
behind the preview ingress (use wss/TLS in front for real deploys). behind the preview ingress (use wss/TLS in front for real deploys).
- No media server: N² mesh — fine for small rooms, needs SFU (mediasoup/livekit) - No media server: N² mesh — fine for small rooms, needs SFU (mediasoup/livekit)
beyond ~6–8 participants. beyond ~6–8 participants.
- For larger rooms or consistently lower bandwidth, replace the mesh with an SFU;
the current quality tuning improves the P2P path but does not change its N² cost.
- Text channels are decorative. No persistence, no rate limiting. - Text channels are decorative. No persistence, no rate limiting.
- Screen-share negotiation storms on join/leave are expected at small scale. - Screen-share negotiation storms on join/leave are expected at small scale.
+3 -8
View File
@@ -36,6 +36,9 @@ spec:
value: "stun:turn.k6n.net:3478,stun:stun.l.google.com:19302" value: "stun:turn.k6n.net:3478,stun:stun.l.google.com:19302"
- name: TURN_URLS - name: TURN_URLS
value: "turn:turn.k6n.net:3478?transport=udp,turn:turn.k6n.net:3478?transport=tcp,turns:turn.k6n.net:5349?transport=tcp" value: "turn:turn.k6n.net:3478?transport=udp,turn:turn.k6n.net:3478?transport=tcp,turns:turn.k6n.net:5349?transport=tcp"
# Prefer direct/STUN paths where they work, with public TURN fallback.
- name: ICE_TRANSPORT_POLICY
value: "all"
- name: TURN_SECRET - name: TURN_SECRET
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
@@ -54,14 +57,6 @@ spec:
name: coturn-auth name: coturn-auth
key: password key: password
optional: true optional: true
- name: TURN_LAN_HOST
value: "10.10.3.46"
- name: TURN_LAN_SECRET
valueFrom:
secretKeyRef:
name: coturn-lan-secret
key: static-auth-secret
optional: true
- name: OTEL_INGEST_KEY - name: OTEL_INGEST_KEY
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
+269 -43
View File
@@ -1,5 +1,6 @@
import * as matrixcs from 'matrix-js-sdk'; import * as matrixcs from 'matrix-js-sdk';
import cytoscape from 'cytoscape'; import cytoscape from 'cytoscape';
import { AudioManager } from './audio-manager.js';
function captureRumIssue(message, severity = 'ERROR', extra = {}) { function captureRumIssue(message, severity = 'ERROR', extra = {}) {
const rum = globalThis.OtelRUM?.last; const rum = globalThis.OtelRUM?.last;
@@ -36,6 +37,7 @@ const FALLBACK_ICE = { iceServers: [{ urls: ['stun:turn.k6n.net:3478', 'stun:stu
const LS_NAME = 'voicepoc.nick'; const LS_NAME = 'voicepoc.nick';
const LS_ICE = 'voicepoc.iceOverride'; const LS_ICE = 'voicepoc.iceOverride';
const LS_AUDIO_INPUT = 'voicepoc.audioInput'; const LS_AUDIO_INPUT = 'voicepoc.audioInput';
const LS_SCREEN_HINT = 'voicepoc.screenHint';
const S = { const S = {
ws: null, ws: null,
@@ -47,6 +49,8 @@ const S = {
screenStreams: new Map(), // streamId -> local MediaStream screenStreams: new Map(), // streamId -> local MediaStream
screenSenders: new Map(), // peerId -> Map(streamId -> RTCRtpSender[]) screenSenders: new Map(), // peerId -> Map(streamId -> RTCRtpSender[])
audioCtx: null, audioCtx: null,
audioManager: new AudioManager(),
rawMicStream: null,
me: { x: 50, y: 50 }, me: { x: 50, y: 50 },
muted: false, muted: false,
deafened: false, deafened: false,
@@ -96,6 +100,113 @@ function toast(msg, kind = '') {
setTimeout(() => t.remove(), 5000); setTimeout(() => t.remove(), 5000);
} }
const ICE_NOTICE_INTERVAL = 15000;
function notifyIceIssue(peer, message, extra = {}) {
const now = Date.now();
const key = `${message}:${extra.url || ''}`;
if (!peer || peer.lastIceNotice?.key !== key || now - peer.lastIceNotice.at > ICE_NOTICE_INTERVAL) {
toast(`${peer?.name || 'Voice'}: ${message}`, 'warn');
if (peer) peer.lastIceNotice = { key, at: now };
}
captureRumIssue(message, 'WARNING', {
peerId: peer?.id,
peerName: peer?.name,
...extra,
});
}
async function selectedIcePath(pc) {
if (!pc?.getStats) return null;
const stats = await pc.getStats();
let pair;
stats.forEach((report) => {
if (report.type === 'candidate-pair' && (report.selected || report.nominated) && report.state === 'succeeded') {
pair = report;
}
});
if (!pair) return null;
const local = stats.get(pair.localCandidateId);
const remote = stats.get(pair.remoteCandidateId);
return {
localType: local?.candidateType,
remoteType: remote?.candidateType,
protocol: pair.protocol,
path: local?.candidateType === 'relay' || remote?.candidateType === 'relay' ? 'TURN relay' : 'direct/STUN',
};
}
async function tuneAudioSender(sender) {
if (!sender?.getParameters || !sender?.setParameters) return;
const params = sender.getParameters();
if (!params.encodings?.length) params.encodings = [{}];
params.encodings[0].maxBitrate = 64000;
params.encodings[0].dtx = false;
params.encodings[0].priority = 'high';
await sender.setParameters(params);
}
function preferOpus(pc) {
const capabilities = globalThis.RTCRtpReceiver?.getCapabilities?.('audio');
const codecs = capabilities?.codecs || [];
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 audio codec', 'WARNING', { error: err?.message || String(err) });
}
}
}
}
function monitorPeerQuality(peer) {
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;
let outbound;
let remoteInbound;
stats.forEach((report) => {
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` : ''})`;
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?.message || String(err),
});
}
}, 5000);
}
function playRetroCue(name) { function playRetroCue(name) {
const ctx = S.audioCtx; const ctx = S.audioCtx;
if (!ctx) return; if (!ctx) return;
@@ -156,7 +267,14 @@ function effectiveIce() {
} }
function microphoneConstraints(deviceId = localStorage.getItem(LS_AUDIO_INPUT)) { function microphoneConstraints(deviceId = localStorage.getItem(LS_AUDIO_INPUT)) {
const audio = { echoCancellation: true, noiseSuppression: true, autoGainControl: true }; const audio = {
echoCancellation: true,
noiseSuppression: false,
autoGainControl: false,
channelCount: 1,
sampleRate: 48000,
sampleSize: 16,
};
if (deviceId) audio.deviceId = { exact: deviceId }; if (deviceId) audio.deviceId = { exact: deviceId };
return { audio }; return { audio };
} }
@@ -197,15 +315,16 @@ async function switchAudioInput(deviceId) {
return; return;
} }
const nextTrack = nextStream.getAudioTracks()[0]; const nextOutput = await S.audioManager.attachInput(nextStream);
const nextTrack = nextOutput.getAudioTracks()[0];
nextTrack.enabled = !S.muted; nextTrack.enabled = !S.muted;
await Promise.all([...S.peers.values()].map(async (peer) => { await Promise.all([...S.peers.values()].map(async (peer) => {
if (!peer.pc) return; if (!peer.pc) return;
const sender = peer.pc.getSenders().find((item) => item.track && item.track.kind === 'audio'); const sender = peer.pc.getSenders().find((item) => item.track && item.track.kind === 'audio');
if (sender) await sender.replaceTrack(nextTrack); if (sender) await sender.replaceTrack(nextTrack);
})); }));
S.micStream.getTracks().forEach((track) => track.stop()); S.rawMicStream = nextStream;
S.micStream = nextStream; S.micStream = nextOutput;
updateMicButton(); updateMicButton();
toast('Microphone changed', 'ok'); toast('Microphone changed', 'ok');
} }
@@ -221,8 +340,8 @@ async function joinVoice(room) {
// Mic permission & AudioContext: request immediately on the user-click gesture // Mic permission & AudioContext: request immediately on the user-click gesture
// so browsers don't block the microphone permission prompt or audio autoplay policy. // so browsers don't block the microphone permission prompt or audio autoplay policy.
if (!S.audioCtx) S.audioCtx = new AudioContext(); await S.audioManager.ensureContext();
await S.audioCtx.resume().catch(() => {}); S.audioCtx = S.audioManager.context;
orientListener(); orientListener();
// Attach a one-time gesture listener in case browser autoplay policy keeps AudioContext suspended // Attach a one-time gesture listener in case browser autoplay policy keeps AudioContext suspended
@@ -235,7 +354,8 @@ async function joinVoice(room) {
window.addEventListener('keydown', resumeCtx, { passive: true }); window.addEventListener('keydown', resumeCtx, { passive: true });
try { try {
S.micStream = await navigator.mediaDevices.getUserMedia(microphoneConstraints()); S.rawMicStream = await navigator.mediaDevices.getUserMedia(microphoneConstraints());
S.micStream = await S.audioManager.attachInput(S.rawMicStream);
populateAudioInputs(); populateAudioInputs();
} catch { } catch {
S.micStream = null; S.micStream = null;
@@ -289,7 +409,9 @@ function leaveVoice(reason) {
S.peers.clear(); S.peers.clear();
if (S.pingTimer) { clearInterval(S.pingTimer); S.pingTimer = null; } if (S.pingTimer) { clearInterval(S.pingTimer); S.pingTimer = null; }
if (S.ws) { try { S.ws.close(); } catch {} S.ws = null; } if (S.ws) { try { S.ws.close(); } catch {} S.ws = null; }
if (S.micStream) { S.micStream.getTracks().forEach((t) => t.stop()); S.micStream = null; } S.audioManager.stop();
S.rawMicStream = null;
S.micStream = null;
// Keep share buttons enabled so clicking one prompts the user to join a channel // Keep share buttons enabled so clicking one prompts the user to join a channel
updateMicButton(); updateMicButton();
document.querySelectorAll('.channel.voice').forEach((c) => c.classList.remove('active')); document.querySelectorAll('.channel.voice').forEach((c) => c.classList.remove('active'));
@@ -448,6 +570,7 @@ function handleSignal(msg) {
// deterministic initiator: lower id offers (no glare storm on join) // deterministic initiator: lower id offers (no glare storm on join)
ensurePC(S.peers.get(p.id), S.myId < p.id); ensurePC(S.peers.get(p.id), S.myId < p.id);
} }
S.me = chooseOpenParticipantPosition();
sendMyPos(true); sendMyPos(true);
renderAll(); renderAll();
setStatus(`voice connected — ${msg.peers.length + 1} in ${msg.room}`, 'online'); setStatus(`voice connected — ${msg.peers.length + 1} in ${msg.room}`, 'online');
@@ -524,6 +647,10 @@ function makePeerRecord(p) {
pendingCandidates: [], micStream: null, screenStreams: new Map(), pendingCandidates: [], micStream: null, screenStreams: new Map(),
gain: null, panner: null, analyser: null, vol: 1, speaking: false, gain: null, panner: null, analyser: null, vol: 1, speaking: false,
resetAttempts: 0, resetAttempts: 0,
lastIceNotice: null,
icePath: null,
qualityTimer: null,
lastQualityNotice: 0,
els: {}, els: {},
}; };
} }
@@ -562,17 +689,40 @@ function ensurePC(peer, initiator) {
if (candidate) send({ type: 'candidate', to: peer.id, candidate }); if (candidate) send({ type: 'candidate', to: peer.id, candidate });
}; };
pc.onicecandidateerror = (event) => { pc.onicecandidateerror = (event) => {
captureRumIssue('WebRTC ICE candidate error', 'WARNING', { notifyIceIssue(peer, `ICE server unavailable (${event.errorCode || 'unknown'})`, {
peerId: peer.id, peerId: peer.id,
iceConnectionState: pc.iceConnectionState,
iceGatheringState: pc.iceGatheringState,
errorCode: event.errorCode, errorCode: event.errorCode,
errorText: event.errorText, errorText: event.errorText,
url: event.url, url: event.url,
}); });
}; };
pc.oniceconnectionstatechange = () => {
const state = pc.iceConnectionState;
if (state === 'disconnected') {
notifyIceIssue(peer, 'ICE disconnected; audio may stop', {
iceConnectionState: state,
iceGatheringState: pc.iceGatheringState,
});
} else if (state === 'failed') {
notifyIceIssue(peer, 'ICE failed; trying TURN relay', {
iceConnectionState: state,
iceGatheringState: pc.iceGatheringState,
});
}
};
pc.onconnectionstatechange = () => { pc.onconnectionstatechange = () => {
renderTiles(); renderTiles();
if (pc.connectionState === 'failed') { if (pc.connectionState === 'failed') {
console.warn('peer connection failed, rebuilding it from scratch', peer.id); console.warn('peer connection failed, rebuilding it from scratch', peer.id);
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', { captureRumIssue('WebRTC peer connection failed', 'ERROR', {
peerId: peer.id, peerId: peer.id,
state: pc.connectionState, state: pc.connectionState,
@@ -581,16 +731,41 @@ function ensurePC(peer, initiator) {
recreatePeerConnection(peer, { announce: true }); recreatePeerConnection(peer, { announce: true });
} else if (pc.connectionState === 'connected') { } else if (pc.connectionState === 'connected') {
peer.resetAttempts = 0; // a successful connection clears the failure count peer.resetAttempts = 0; // a successful connection clears the failure count
selectedIcePath(pc).then((path) => {
if (!path) return;
peer.icePath = path;
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?.message || String(err),
});
});
monitorPeerQuality(peer);
} }
}; };
pc.ontrack = (ev) => onRemoteTrack(peer, ev); pc.ontrack = (ev) => onRemoteTrack(peer, ev);
// send my mic; if I have none, ask to receive theirs // send my mic; if I have none, ask to receive theirs
if (S.micStream) { if (S.micStream) {
for (const track of S.micStream.getAudioTracks()) pc.addTrack(track, S.micStream); for (const track of S.micStream.getAudioTracks()) {
const sender = pc.addTrack(track, S.micStream);
tuneAudioSender(sender).catch((err) => captureRumIssue('Could not tune Opus audio sender', 'WARNING', {
peerId: peer.id,
error: err?.message || String(err),
}));
}
} else { } else {
pc.addTransceiver('audio', { direction: 'recvonly' }); pc.addTransceiver('audio', { direction: 'recvonly' });
} }
preferOpus(pc);
// send my screen share if I am currently sharing when this peer connects // send my screen share if I am currently sharing when this peer connects
if (S.sharing) { if (S.sharing) {
@@ -633,6 +808,7 @@ function recreatePeerConnection(peer, { announce }) {
send({ type: 'reset', to: peer.id }); send({ type: 'reset', to: peer.id });
} }
if (peer.pc) { try { peer.pc.close(); } catch {} } if (peer.pc) { try { peer.pc.close(); } catch {} }
if (peer.qualityTimer) { clearInterval(peer.qualityTimer); peer.qualityTimer = null; }
peer.pc = null; peer.pc = null;
peer.makingOffer = false; peer.makingOffer = false;
peer.ignoreOffer = false; peer.ignoreOffer = false;
@@ -677,6 +853,7 @@ async function onCandidate(peer, candidate) {
function destroyPeer(peer) { function destroyPeer(peer) {
if (peer.pc) { try { peer.pc.close(); } catch {} peer.pc = null; } if (peer.pc) { try { peer.pc.close(); } catch {} peer.pc = null; }
if (peer.qualityTimer) { clearInterval(peer.qualityTimer); peer.qualityTimer = null; }
detachSpatial(peer); detachSpatial(peer);
if (peer.audioEl) { if (peer.audioEl) {
try { peer.audioEl.pause(); peer.audioEl.srcObject = null; } catch {} try { peer.audioEl.pause(); peer.audioEl.srcObject = null; } catch {}
@@ -701,9 +878,26 @@ function onRemoteTrack(peer, ev) {
track.addEventListener('ended', () => { track.addEventListener('ended', () => {
if (peer.screenStreams.get(stream.id) === stream) { if (peer.screenStreams.get(stream.id) === stream) {
peer.screenStreams.delete(stream.id); peer.screenStreams.delete(stream.id);
if (!peer.screenStreams.size) {
peer.state = { ...peer.state, sharing: false };
captureRumIssue('Remote screen share ended', 'INFO', {
peerId: peer.id,
peerName: peer.name,
streamId: stream.id,
});
}
renderScreens(); renderScreens();
renderTiles();
} }
}); });
stream.addEventListener('inactive', () => {
if (peer.screenStreams.get(stream.id) === stream) {
peer.screenStreams.delete(stream.id);
peer.state = { ...peer.state, sharing: false };
renderScreens();
renderTiles();
}
}, { once: true });
renderScreens(); renderScreens();
if (isNewScreenShare) playRetroCue('share'); if (isNewScreenShare) playRetroCue('share');
console.log('remote screen share from', peer.name); console.log('remote screen share from', peer.name);
@@ -822,9 +1016,15 @@ async function startScreenShare() {
for (const peer of S.peers.values()) addScreenToPeer(peer); for (const peer of S.peers.values()) addScreenToPeer(peer);
const videoTrack = stream.getVideoTracks()[0]; const videoTrack = stream.getVideoTracks()[0];
if (videoTrack) { if (videoTrack) {
const screenHint = localStorage.getItem(LS_SCREEN_HINT) || '';
if ('contentHint' in videoTrack) videoTrack.contentHint = screenHint;
videoTrack.addEventListener('ended', () => stopScreenShare(stream)); videoTrack.addEventListener('ended', () => stopScreenShare(stream));
} }
renderScreens(); renderScreens();
// Open a desktop preview automatically; mobile keeps the in-page preview.
if (window.matchMedia('(min-width: 700px)').matches) {
popOutShare({ key: `me:${stream.id}`, name: `${S.name} (you)`, stream, local: true });
}
renderTiles(); renderTiles();
for (const btn of shareButtons) btn.classList.add('active'); for (const btn of shareButtons) btn.classList.add('active');
toast('You are sharing your screen', 'ok'); toast('You are sharing your screen', 'ok');
@@ -873,6 +1073,21 @@ function stopScreenShare(stream, silent) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// position sync // position sync
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function chooseOpenParticipantPosition() {
const occupied = [...S.peers.values()].map((peer) => ({ x: peer.x, y: peer.y }));
const candidates = [];
for (let row = 0; row < 5; row++) {
for (let col = 0; col < 7; col++) {
candidates.push({ x: 15 + col * 11.7, y: 15 + row * 17.5 });
}
}
if (!occupied.length) return { x: 50, y: 50 };
return candidates.sort((a, b) => {
const distance = (point) => Math.min(...occupied.map((other) => Math.hypot(point.x - other.x, point.y - other.y)));
return distance(b) - distance(a);
})[0];
}
let lastPosSend = 0; let lastPosSend = 0;
function sendMyPos(force) { function sendMyPos(force) {
const now = performance.now(); const now = performance.now();
@@ -896,16 +1111,20 @@ function rms(analyser) {
return Math.sqrt(sum / meterBuf.length); return Math.sqrt(sum / meterBuf.length);
} }
let localAnalyser = null; function updateMicSignal(level = S.audioManager.getLevel()) {
const meter = $('#mic-level');
const value = $('#mic-level-value');
const clipping = $('#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);
}
setInterval(() => { setInterval(() => {
if (!S.audioCtx) return; if (!S.audioCtx) return;
if (S.micStream && !localAnalyser) { const localLevel = S.audioManager.getLevel();
const src = S.audioCtx.createMediaStreamSource(S.micStream); updateMicSignal(localLevel);
localAnalyser = S.audioCtx.createAnalyser(); const meSpeaking = S.micStream && !S.muted && localLevel.rms > 0.04;
localAnalyser.fftSize = 256;
src.connect(localAnalyser); // tap only — not connected to destination (no echo)
}
const meSpeaking = localAnalyser && !S.muted && rms(localAnalyser) > 0.04;
els.meAvatar.classList.toggle('speaking', !!meSpeaking); els.meAvatar.classList.toggle('speaking', !!meSpeaking);
let changed = false; let changed = false;
for (const peer of S.peers.values()) { for (const peer of S.peers.values()) {
@@ -1945,29 +2164,6 @@ setInterval(() => {
} }
}, 5000); }, 5000);
// Spawn me slightly off-center so people don't land on top of each other.
// A single hashed angle on a *fixed* radius ring (the previous approach)
// puts anyone whose name hashes to a nearby angle almost exactly on top of
// each other too — on a small ring, "nearby angle" is only a few pixels of
// arc, and with just 360 angle buckets that's common (especially with the
// same recurring guest-xxxx test names). Hashing the radius independently
// from the angle means an angle collision no longer implies a full overlap.
// Both stay small enough that at refDistance=4/rolloffFactor=1.4 (see
// updatePanner) nobody starts out attenuated — this is purely about the
// dots being visually distinguishable, not audio range.
(() => {
const key = S.myId || S.name;
let hAngle = 0, hRadius = 0;
for (const c of key) {
hAngle = (hAngle * 33 + c.charCodeAt(0)) >>> 0;
hRadius = (hRadius * 17 + c.charCodeAt(0) + 7) >>> 0;
}
const a = (hAngle % 360) * (Math.PI / 180);
const r = 8 + (hRadius % 12); // 8..19 room units
S.me.x = 50 + r * Math.cos(a);
S.me.y = 50 + r * Math.sin(a);
})();
window.addEventListener('keydown', (ev) => { window.addEventListener('keydown', (ev) => {
if (ev.target.tagName === 'INPUT' || ev.target.tagName === 'TEXTAREA') return; if (ev.target.tagName === 'INPUT' || ev.target.tagName === 'TEXTAREA') return;
if (ev.key === 'm') toggleMic(); if (ev.key === 'm') toggleMic();
@@ -1982,6 +2178,11 @@ navigator.mediaDevices?.addEventListener('devicechange', populateAudioInputs);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function openSettings() { function openSettings() {
populateAudioInputs(); populateAudioInputs();
const gain = $('#mic-gain');
if (gain) gain.value = S.audioManager.gain;
updateMicGainUi();
const screenHint = $('#screen-hint');
if (screenHint) screenHint.value = localStorage.getItem(LS_SCREEN_HINT) || '';
const override = localStorage.getItem(LS_ICE); const override = localStorage.getItem(LS_ICE);
$('#override-enabled').checked = !!override; $('#override-enabled').checked = !!override;
const current = override ? JSON.parse(override) : effectiveIce(); const current = override ? JSON.parse(override) : effectiveIce();
@@ -1992,15 +2193,40 @@ function openSettings() {
); );
$('#modal-backdrop').classList.remove('hidden'); $('#modal-backdrop').classList.remove('hidden');
} }
function updateMicGainUi() {
const gain = S.audioManager.gain;
const input = $('#mic-gain');
const value = $('#mic-gain-value');
if (input) input.value = gain;
if (value) value.textContent = `${Math.round(gain * 100)}%`;
}
$('#mic-gain').oninput = (ev) => {
S.audioManager.setGain(ev.target.value);
updateMicGainUi();
};
$('#screen-hint').onchange = (ev) => {
localStorage.setItem(LS_SCREEN_HINT, ev.target.value);
toast('Screen-share hint saved — applies next time you share', 'ok');
};
$('#audio-input').onchange = (ev) => switchAudioInput(ev.target.value); $('#audio-input').onchange = (ev) => switchAudioInput(ev.target.value);
$('#ice-cancel').onclick = () => $('#modal-backdrop').classList.add('hidden'); $('#ice-cancel').onclick = () => $('#modal-backdrop').classList.add('hidden');
$('#modal-backdrop').onclick = (ev) => { $('#modal-backdrop').onclick = (ev) => {
if (ev.target === $('#modal-backdrop')) $('#modal-backdrop').classList.add('hidden'); if (ev.target === $('#modal-backdrop')) $('#modal-backdrop').classList.add('hidden');
}; };
$('#ice-reset').onclick = () => { function resetIceSettings(reload = false) {
localStorage.removeItem(LS_ICE); localStorage.removeItem(LS_ICE);
$('#override-enabled').checked = false; $('#override-enabled').checked = false;
fetchIce().then((server) => { $('#ice-json').value = JSON.stringify(server, null, 2); }); fetchIce().then((server) => {
$('#ice-json').value = JSON.stringify(server, null, 2);
if (reload) location.reload();
}).catch(() => {
if (reload) location.reload();
});
}
$('#ice-reset').onclick = () => resetIceSettings();
$('#btn-reset-ice-mobile').onclick = () => {
toast('Resetting saved ICE settings…', 'ok');
resetIceSettings(true);
}; };
$('#ice-save').onclick = () => { $('#ice-save').onclick = () => {
const enabled = $('#override-enabled').checked; const enabled = $('#override-enabled').checked;
+73
View File
@@ -0,0 +1,73 @@
export class AudioManager {
constructor() {
this.context = null;
this.rawStream = null;
this.outputStream = null;
this.source = null;
this.gainNode = null;
this.analyser = null;
this.meterSink = null;
this.meterData = new Float32Array(1024);
this.gain = Number(localStorage.getItem('voicepoc.micGain') || 1);
}
async ensureContext() {
if (!this.context) this.context = new AudioContext();
await this.context.resume().catch(() => {});
return this.context;
}
async attachInput(stream) {
await this.ensureContext();
this.detachInput();
this.rawStream = stream;
this.source = this.context.createMediaStreamSource(stream);
this.gainNode = this.context.createGain();
this.gainNode.gain.value = this.gain;
this.analyser = this.context.createAnalyser();
this.analyser.fftSize = 1024;
this.meterSink = this.context.createGain();
this.meterSink.gain.value = 0;
const destination = this.context.createMediaStreamDestination();
this.source.connect(this.gainNode).connect(this.analyser);
// Keep the analyser alive without sending the microphone to the speakers.
this.analyser.connect(this.meterSink).connect(this.context.destination);
this.gainNode.connect(destination);
this.outputStream = destination.stream;
return this.outputStream;
}
detachInput() {
for (const node of [this.source, this.gainNode, this.analyser, this.meterSink]) {
try { node?.disconnect(); } catch {}
}
this.outputStream?.getTracks().forEach((track) => track.stop());
this.rawStream?.getTracks().forEach((track) => track.stop());
this.source = this.gainNode = this.analyser = this.meterSink = null;
this.outputStream = this.rawStream = null;
}
setGain(value) {
this.gain = Math.max(0, Math.min(2, Number(value) || 0));
localStorage.setItem('voicepoc.micGain', String(this.gain));
if (this.gainNode) this.gainNode.gain.setTargetAtTime(this.gain, this.context.currentTime, 0.015);
}
getLevel() {
if (!this.analyser) return { rms: 0, peak: 0, clipping: false };
this.analyser.getFloatTimeDomainData(this.meterData);
let sum = 0;
let peak = 0;
for (const sample of this.meterData) {
const magnitude = Math.abs(sample);
sum += sample * sample;
peak = Math.max(peak, magnitude);
}
const rms = Math.sqrt(sum / this.meterData.length);
return { rms, peak, clipping: peak >= 0.98 };
}
stop() {
this.detachInput();
}
}
+20
View File
@@ -126,6 +126,7 @@
<button class="ctl mirror-mic" title="Toggle mic (M)"><span class="material-symbols-outlined">mic</span></button> <button class="ctl mirror-mic" title="Toggle mic (M)"><span class="material-symbols-outlined">mic</span></button>
<button class="ctl mirror-deafen" title="Toggle deafen (D)"><span class="material-symbols-outlined">headphones</span></button> <button class="ctl mirror-deafen" title="Toggle deafen (D)"><span class="material-symbols-outlined">headphones</span></button>
<button class="ctl mirror-share" title="Share your screen (S)"><span class="material-symbols-outlined">present_to_all</span></button> <button class="ctl mirror-share" title="Share your screen (S)"><span class="material-symbols-outlined">present_to_all</span></button>
<button id="btn-reset-ice-mobile" class="ctl" title="Reset saved ICE settings"><span class="material-symbols-outlined">restart_alt</span></button>
</div> </div>
<!-- settings modal --> <!-- settings modal -->
@@ -137,7 +138,26 @@
<select id="audio-input" class="settings-select"> <select id="audio-input" class="settings-select">
<option value="">System default</option> <option value="">System default</option>
</select> </select>
<label class="field-label" for="mic-gain">Microphone gain</label>
<div class="gain-control">
<input id="mic-gain" type="range" min="0" max="2" step="0.05" value="1" />
<span id="mic-gain-value">100%</span>
</div>
<div class="signal-preview" aria-live="polite">
<span>Signal</span>
<meter id="mic-level" min="0" max="1" low="0.35" high="0.8" optimum="0.55" value="0"></meter>
<span id="mic-level-value">-∞ dB</span>
</div>
<p id="mic-clipping" class="hint hidden">Clipping detected — reduce microphone gain.</p>
<p class="hint">Changes apply immediately when connected, or the next time you join.</p> <p class="hint">Changes apply immediately when connected, or the next time you join.</p>
<h3>Screen sharing</h3>
<label class="field-label" for="screen-hint">Optimization</label>
<select id="screen-hint" class="settings-select">
<option value="">Browser default</option>
<option value="detail">Details / text editor / terminal</option>
<option value="motion">Gameplay / motion</option>
</select>
<p class="hint">Details favors sharp text; motion favors smoother movement.</p>
<p class="hint">Overrides the server-provided ICE configuration (stored in <code>localStorage</code>, applied on next join).</p> <p class="hint">Overrides the server-provided ICE configuration (stored in <code>localStorage</code>, applied on next join).</p>
<label class="toggle-row"> <label class="toggle-row">
<input type="checkbox" id="override-enabled" /> <input type="checkbox" id="override-enabled" />
+4
View File
@@ -402,6 +402,10 @@ body.sidebar-collapsed #btn-sidebar-expand { display: grid; }
border-radius: 6px; padding: 9px 10px; margin-bottom: 4px; font: inherit; border-radius: 6px; padding: 9px 10px; margin-bottom: 4px; font: inherit;
} }
.settings-select:focus { outline: none; border-color: var(--brand); } .settings-select:focus { outline: none; border-color: var(--brand); }
.gain-control, .signal-preview { display: flex; align-items: center; gap: 10px; margin: 6px 0 10px; }
.gain-control input { flex: 1; accent-color: var(--brand); }
.gain-control span, .signal-preview > span:last-child { min-width: 48px; text-align: right; font-variant-numeric: tabular-nums; }
.signal-preview meter { flex: 1; height: 10px; }
#ice-json { #ice-json {
width: 100%; background: var(--bg-0); color: var(--text-0); border: 1px solid var(--bg-4); width: 100%; background: var(--bg-0); color: var(--text-0); border: 1px solid var(--bg-4);
border-radius: 6px; padding: 10px; font: 12px/1.5 ui-monospace, "Cascadia Code", monospace; border-radius: 6px; padding: 10px; font: 12px/1.5 ui-monospace, "Cascadia Code", monospace;
+7 -2
View File
@@ -24,6 +24,7 @@ const STATIC_DIR = fs.existsSync(path.join(__dirname, 'dist'))
// STUN_URLS comma-separated stun: urls // 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 creds for all TURN 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 // TURN_LAN_HOST / TURN_LAN_SECRET optional second, LAN-only TURN relay
// (own coturn instance + secret, reachable only from // (own coturn instance + secret, reachable only from
// the local network via its own LoadBalancer IP with // the local network via its own LoadBalancer IP with
@@ -85,7 +86,12 @@ function iceConfig() {
}); });
} }
} }
return { iceServers }; const config = { iceServers };
const iceTransportPolicy = process.env.ICE_TRANSPORT_POLICY;
if (iceTransportPolicy && ['all', 'relay', 'none'].includes(iceTransportPolicy)) {
config.iceTransportPolicy = iceTransportPolicy;
}
return config;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -323,4 +329,3 @@ if (require.main === module) {
module.exports = { server, wss, iceConfig, allRoomsSnapshot, interval, startServer }; module.exports = { server, wss, iceConfig, allRoomsSnapshot, interval, startServer };
+10
View File
@@ -91,3 +91,13 @@ test('TURN credentials generation with TURN_SECRET', () => {
delete process.env.TURN_SECRET; 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;
}
});