diff --git a/voice-poc/.codesearch/codesearch.db b/voice-poc/.codesearch/codesearch.db new file mode 100644 index 0000000..9e7df26 Binary files /dev/null and b/voice-poc/.codesearch/codesearch.db differ diff --git a/voice-poc/.gitignore b/voice-poc/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/voice-poc/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/voice-poc/README.md b/voice-poc/README.md new file mode 100644 index 0000000..2076e86 --- /dev/null +++ b/voice-poc/README.md @@ -0,0 +1,48 @@ +# gibbz voice — POC + +Discord-clone proof of concept: peer-to-peer WebRTC voice chat + screen sharing +with position-based spatial audio and pluggable STUN/TURN. + +Run (Node 18+, only dep is `ws`): + +```sh +npm install +PORT=8081 npm start +# open http://localhost:8081, click a voice channel, allow mic +``` + +## What works + +- Mesh WebRTC voice (perfect negotiation; lowest peer id initiates) +- Screen share with audio (mic is spatialized, screen audio plays direct) +- Spatial audio: drag your avatar on the plane; remote mics run through a + `PannerNode` (HRTF, inverse distance). Positions sync over signaling. +- Custom STUN/TURN: gear icon → JSON override in `localStorage`, applied on + next join. Server default comes from env (below). +- Mute (M), deafen (D), per-peer volume, speaking indicators. + +## ICE configuration (server side) + +| Env var | Meaning | +|---|---| +| `STUN_URLS` | comma-separated `stun:` urls (Google defaults) | +| `TURN_URLS` | comma-separated `turn:`/`turns:` urls | +| `TURN_USERNAME` / `TURN_CREDENTIAL` | static TURN creds | +| `ICE_SERVERS_JSON` | full `{"iceServers":[...]}` override, wins over the above | +| `PORT` / `HOST` | listen address (default 8081 / 0.0.0.0) | + +Example with coturn: + +```sh +TURN_URLS='turn:turn.example.com:3478?transport=udp,turns:turn.example.com:5349' \ +TURN_USERNAME=mats TURN_CREDENTIAL=secret npm start +``` + +## POC shortcuts (productionize later) + +- No auth, no reconnection, rooms are in-memory, signaling is unencrypted ws + behind the preview ingress (use wss/TLS in front for real deploys). +- No media server: N² mesh — fine for small rooms, needs SFU (mediasoup/livekit) + beyond ~6–8 participants. +- Text channels are decorative. No persistence, no rate limiting. +- Screen-share negotiation storms on join/leave are expected at small scale. diff --git a/voice-poc/package-lock.json b/voice-poc/package-lock.json new file mode 100644 index 0000000..67055f4 --- /dev/null +++ b/voice-poc/package-lock.json @@ -0,0 +1,36 @@ +{ + "name": "gibbz-voice-poc", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gibbz-voice-poc", + "version": "0.1.0", + "dependencies": { + "ws": "^8.18.0" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/voice-poc/package.json b/voice-poc/package.json new file mode 100644 index 0000000..8b421c3 --- /dev/null +++ b/voice-poc/package.json @@ -0,0 +1,12 @@ +{ + "name": "gibbz-voice-poc", + "private": true, + "version": "0.1.0", + "description": "Discord-clone POC: WebRTC voice chat + screen sharing + spatial audio", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "ws": "^8.18.0" + } +} diff --git a/voice-poc/public/app.js b/voice-poc/public/app.js new file mode 100644 index 0000000..228df45 --- /dev/null +++ b/voice-poc/public/app.js @@ -0,0 +1,794 @@ +/* gibbz voice POC — WebRTC mesh voice + screen share + spatial audio. + POC shortcuts: no auth, no reconnection, no text chat, single audio context. */ +'use strict'; + +// --------------------------------------------------------------------------- +// state +// --------------------------------------------------------------------------- +const ROOM_UNITS = 100; // spatial plane is 0..100 x 0..100 +const SPATIAL_SCALE = 0.12; // room units -> audio world units +const FALLBACK_ICE = { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }; +const LS_NAME = 'voicepoc.nick'; +const LS_ICE = 'voicepoc.iceOverride'; + +const S = { + ws: null, + myId: null, + room: null, + name: localStorage.getItem(LS_NAME) || '', + peers: new Map(), // id -> peer record + micStream: null, + screenStream: null, + screenSenders: new Map(), // peerId -> [RTCRtpSender] + audioCtx: null, + me: { x: 50, y: 50 }, + muted: false, + deafened: false, + iceServers: null, // resolved on join + sharing: false, + connecting: false, +}; +window.__dbg = S; // POC debug handle + +const $ = (sel) => document.querySelector(sel); +const els = { + stage: $('#stage'), screens: $('#screens'), tiles: $('#tiles'), + spatial: $('#spatial'), status: $('#voice-status'), nick: $('#nick'), + meAvatar: $('#me-avatar'), mic: $('#btn-mic'), deafen: $('#btn-deafen'), +}; + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- +function toast(msg, kind = '') { + const t = document.createElement('div'); + t.className = `toast ${kind}`; + t.textContent = msg; + $('#toasts').appendChild(t); + setTimeout(() => t.remove(), 5000); +} + +const PEER_COLORS = ['#5865f2', '#23a55a', '#f0b232', '#eb459e', '#3ba55c', '#faa61a', '#e67e22', '#9b59b6']; +function colorFor(id) { + let h = 0; + for (const c of id) h = (h * 31 + c.charCodeAt(0)) >>> 0; + return PEER_COLORS[h % PEER_COLORS.length]; +} +function setAvatar(el, name, id) { + el.style.background = colorFor(id); + el.textContent = (name || '?').slice(0, 1).toUpperCase(); +} + +function effectiveIce() { + const raw = localStorage.getItem(LS_ICE); + if (raw) { + try { return JSON.parse(raw); } catch { /* fall through */ } + } + return S.iceServers || FALLBACK_ICE; +} + +// --------------------------------------------------------------------------- +// join / leave +// --------------------------------------------------------------------------- +async function joinVoice(room) { + if (S.room === room || S.connecting) return; + if (S.room) leaveVoice('switching channel'); + S.connecting = true; + setStatus(`connecting to ${room}…`); + + try { + S.iceServers = await fetchIce(); + } catch { + toast('Could not load ICE config from server — using fallback STUN', 'warn'); + } + + // mic (join anyway without one — listen-only) + try { + S.micStream = await navigator.mediaDevices.getUserMedia({ + audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, + }); + } catch { + S.micStream = null; + toast('No microphone available — joining in listen-only mode', 'warn'); + } + els.mic.disabled = !S.micStream; + S.muted = false; + + // audio context must exist after a user gesture — the channel click is one + if (!S.audioCtx) S.audioCtx = new AudioContext(); + await S.audioCtx.resume().catch(() => {}); + orientListener(); + + await openSignaling(room); + S.connecting = false; +} + +async function fetchIce() { + const res = await fetch('/api/ice'); + if (!res.ok) throw new Error(`ice ${res.status}`); + return res.json(); +} + +function leaveVoice(reason) { + if (!S.room && !S.ws) return; + if (S.sharing) stopScreenShare(true); + for (const peer of S.peers.values()) destroyPeer(peer); + S.peers.clear(); + if (S.ws) { try { S.ws.close(); } catch {} S.ws = null; } + if (S.micStream) { S.micStream.getTracks().forEach((t) => t.stop()); S.micStream = null; } + els.mic.disabled = false; + updateMicButton(); + document.querySelectorAll('.channel.voice').forEach((c) => c.classList.remove('active')); + S.room = null; + S.myId = null; + S.muted = false; + S.deafened = false; + updateDeafenButton(); + renderAll(); + setStatus(reason || 'not connected'); +} + +function setStatus(text) { els.status.textContent = text; } + +// --------------------------------------------------------------------------- +// signaling +// --------------------------------------------------------------------------- +function openSignaling(room) { + return new Promise((resolve, reject) => { + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + const url = `${proto}://${location.host}/ws?room=${encodeURIComponent(room)}&name=${encodeURIComponent(S.name)}`; + const ws = new WebSocket(url); + S.ws = ws; + ws.onerror = () => { S.connecting = false; setStatus('connection failed'); reject(new Error('websocket failed')); }; + ws.onmessage = (ev) => { + let msg; + try { msg = JSON.parse(ev.data); } catch { return; } + try { handleSignal(msg); } catch (err) { console.error('signal handler error', msg.type, err); } + }; + ws.onclose = () => { + if (S.ws === ws && S.room) { + toast('Signaling connection lost', 'error'); + leaveVoice('disconnected'); + } + }; + ws.onopen = () => resolve(); + }); +} + +function send(msg) { + if (S.ws && S.ws.readyState === 1) S.ws.send(JSON.stringify(msg)); +} + +function handleSignal(msg) { + switch (msg.type) { + case 'hello': { + S.myId = msg.id; + for (const p of msg.peers) { + S.peers.set(p.id, makePeerRecord(p)); + // deterministic initiator: lower id offers (no glare storm on join) + ensurePC(S.peers.get(p.id), S.myId < p.id); + } + sendMyPos(true); + renderAll(); + setStatus(`voice connected — ${msg.peers.length + 1} in ${msg.room}`); + toast(`Joined ${msg.room}`, 'ok'); + break; + } + case 'joined': + S.peers.set(msg.peer.id, makePeerRecord(msg.peer)); + renderAll(); + toast(`${msg.peer.name} joined`, 'ok'); + break; + case 'left': { + const peer = S.peers.get(msg.id); + if (peer) { + destroyPeer(peer); + S.peers.delete(msg.id); + 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; updatePanner(peer); } + break; + } + case 'state': { + const peer = S.peers.get(msg.from); + if (peer) { peer.state = msg.state; renderTiles(); } + break; + } + case 'offer': + case 'answer': { + const peer = ensurePC(S.peers.get(msg.from) || createOrphanPeer(msg.from), false); + onDescription(peer, msg.sdp).catch((e) => console.error('description failed', e)); + break; + } + case 'candidate': { + const peer = S.peers.get(msg.from); + if (!peer || !peer.pc) return; // candidates only after description path created pc + onCandidate(peer, msg.candidate).catch((e) => console.error('candidate failed', e)); + break; + } + default: + break; + } +} + +// --------------------------------------------------------------------------- +// peer records + WebRTC (perfect negotiation) +// --------------------------------------------------------------------------- +function makePeerRecord(p) { + return { + id: p.id, name: p.name, x: p.x, y: p.y, + state: p.state || { muted: false, deafened: false, sharing: false }, + pc: null, polite: false, makingOffer: false, ignoreOffer: false, + pendingCandidates: [], micStream: null, screenStream: null, + gain: null, panner: null, analyser: null, vol: 1, speaking: false, + els: {}, + }; +} + +function createOrphanPeer(id) { + const p = makePeerRecord({ id, name: 'peer-' + id.slice(0, 4), x: 50, y: 50 }); + S.peers.set(id, p); + return p; +} + +function ensurePC(peer, initiator) { + if (peer.pc) return peer; + const pc = new RTCPeerConnection({ ...effectiveIce(), iceCandidatePoolSize: 4 }); + peer.pc = pc; + peer.polite = S.myId > peer.id; + + pc.onnegotiationneeded = async () => { + try { + peer.makingOffer = true; + await pc.setLocalDescription(); + send({ type: 'offer', to: peer.id, sdp: pc.localDescription }); + } catch (err) { + console.error('negotiation failed', err); + } finally { + peer.makingOffer = false; + } + }; + pc.onicecandidate = ({ candidate }) => { + if (candidate) send({ type: 'candidate', to: peer.id, candidate }); + }; + pc.onconnectionstatechange = () => { + renderTiles(); + if (pc.connectionState === 'failed') { + console.warn('peer connection failed, restarting ICE', peer.id); + try { pc.restartIce(); } catch {} + } + }; + pc.ontrack = (ev) => onRemoteTrack(peer, ev); + + // send my mic; if I have none, ask to receive theirs + if (S.micStream) { + for (const track of S.micStream.getAudioTracks()) pc.addTrack(track, S.micStream); + } else { + pc.addTransceiver('audio', { direction: 'recvonly' }); + } + + if (initiator) pc.onnegotiationneeded(); + return peer; +} + +async function onDescription(peer, desc) { + 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); // 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)); + } + if (desc.type === 'offer') { + await pc.setLocalDescription(); + send({ type: 'answer', to: peer.id, sdp: pc.localDescription }); + } +} + +async function onCandidate(peer, candidate) { + if (peer.ignoreOffer) return; + if (!peer.pc.remoteDescription) { + peer.pendingCandidates.push(candidate); + return; + } + await peer.pc.addIceCandidate(candidate); +} + +function destroyPeer(peer) { + if (peer.pc) { try { peer.pc.close(); } catch {} peer.pc = null; } + detachSpatial(peer); + peer.micStream = null; + peer.screenStream = null; + peer.els = {}; + renderScreens(); +} + +// --------------------------------------------------------------------------- +// remote media: mic -> spatial chain, screen -> big video + direct audio +// --------------------------------------------------------------------------- +function onRemoteTrack(peer, ev) { + const stream = ev.streams[0]; + if (!stream) return; + if (stream.getVideoTracks().length > 0) { + peer.screenStream = stream; + stream.getVideoTracks().forEach((t) => { + t.addEventListener('ended', () => { + if (peer.screenStream === stream) { peer.screenStream = null; renderScreens(); } + }); + }); + renderScreens(); + console.log('remote screen share from', peer.name); + } else { + peer.micStream = stream; + attachSpatial(peer, stream); + console.log('remote mic from', peer.name); + } + renderTiles(); +} + +function attachSpatial(peer, stream) { + const ctx = S.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 = S.deafened ? 0 : peer.vol; + const analyser = ctx.createAnalyser(); + analyser.fftSize = 256; + src.connect(panner).connect(gain).connect(analyser).connect(ctx.destination); + Object.assign(peer, { panner, gain, analyser }); + updatePanner(peer); +} + +function detachSpatial(peer) { + if (peer.gain) { try { peer.gain.disconnect(); } catch {} } + if (peer.panner) { try { peer.panner.disconnect(); } catch {} } + peer.panner = peer.gain = peer.analyser = null; +} + +function updatePanner(peer) { + if (!peer.panner) return; + const x = (peer.x - S.me.x) * SPATIAL_SCALE; + const z = (peer.y - S.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); + } +} + +function orientListener() { + const l = S.audioCtx && S.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.setOrientation(0, 0, -1, 0, 1, 0); + } +} + +// --------------------------------------------------------------------------- +// screen share (local) +// --------------------------------------------------------------------------- +async function startScreenShare() { + if (S.sharing || !S.room) return; + let stream; + try { + stream = await navigator.mediaDevices.getDisplayMedia({ + video: { frameRate: 30 }, audio: true, + }); + } catch { + return; // user cancelled + } + S.screenStream = stream; + S.sharing = true; + send({ type: 'state', state: { muted: S.muted, deafened: S.deafened, sharing: true } }); + + for (const peer of S.peers.values()) addScreenToPeer(peer); + stream.getVideoTracks()[0].addEventListener('ended', () => stopScreenShare()); + renderScreens(); + renderTiles(); + toast('You are sharing your screen', 'ok'); +} + +function addScreenToPeer(peer) { + if (!peer.pc || !S.screenStream) return; + const senders = []; + for (const track of S.screenStream.getTracks()) { + senders.push(peer.pc.addTrack(track, S.screenStream)); + } + S.screenSenders.set(peer.id, senders); // renegotiation fires automatically +} + +function stopScreenShare(silent) { + if (!S.sharing) return; + S.sharing = false; + for (const [peerId, senders] of S.screenSenders) { + const peer = S.peers.get(peerId); + if (peer && peer.pc) for (const s of senders) { try { peer.pc.removeTrack(s); } catch {} } + } + S.screenSenders.clear(); + if (S.screenStream) { S.screenStream.getTracks().forEach((t) => t.stop()); S.screenStream = null; } + send({ type: 'state', state: { muted: S.muted, deafened: S.deafened, sharing: false } }); + renderScreens(); + renderTiles(); + if (!silent) toast('Screen share stopped'); +} + +// --------------------------------------------------------------------------- +// position sync +// --------------------------------------------------------------------------- +let lastPosSend = 0; +function sendMyPos(force) { + const now = performance.now(); + if (!force && now - lastPosSend < 60) return; + lastPosSend = now; + send({ type: 'pos', x: S.me.x, y: S.me.y }); + for (const peer of S.peers.values()) updatePanner(peer); +} + +// --------------------------------------------------------------------------- +// speaking detection (local meter + per-peer analyser RMS) +// --------------------------------------------------------------------------- +const meterBuf = new Uint8Array(256); +function rms(analyser) { + analyser.getByteTimeDomainData(meterBuf); + let sum = 0; + for (let i = 0; i < meterBuf.length; i++) { + const v = (meterBuf[i] - 128) / 128; + sum += v * v; + } + return Math.sqrt(sum / meterBuf.length); +} + +let localAnalyser = null; +setInterval(() => { + if (!S.audioCtx) return; + if (S.micStream && !localAnalyser) { + const src = S.audioCtx.createMediaStreamSource(S.micStream); + localAnalyser = S.audioCtx.createAnalyser(); + 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); + let changed = false; + for (const peer of S.peers.values()) { + const speaking = !!(peer.analyser && !S.deafened && rms(peer.analyser) > 0.03); + if (speaking !== peer.speaking) { peer.speaking = speaking; changed = true; } + } + if (changed) renderTiles(); +}, 150); + +// --------------------------------------------------------------------------- +// rendering +// --------------------------------------------------------------------------- +function renderAll() { + renderTiles(); + renderScreens(); + renderStageEmpty(); + renderChannelCounts(); +} + +function renderStageEmpty() { + const anyone = S.peers.size > 0 || S.sharing || S.room; + els.stage.classList.toggle('empty', !anyone); +} + +function renderChannelCounts() { + // POC: server does not expose cross-room rosters; count is local-room only + const counts = {}; + for (const p of S.peers.values()) counts[S.room] = (counts[S.room] || 0) + 1; + document.querySelectorAll('[data-room-count]').forEach((el) => { + const n = counts[el.dataset.roomCount] || 0; + el.textContent = n ? `${n + (el.dataset.roomCount === S.room ? 1 : 0)}` : ''; + }); +} + +function renderTiles() { + els.tiles.innerHTML = ''; + for (const peer of S.peers.values()) { + const tile = document.createElement('div'); + tile.className = 'tile' + (peer.speaking ? ' speaking' : ''); + const av = document.createElement('div'); + av.className = 'avatar'; + setAvatar(av, peer.name, peer.id); + const name = document.createElement('div'); + name.className = 'name'; + name.textContent = peer.name; + const sub = document.createElement('div'); + sub.className = 'sub'; + const bits = []; + if (!peer.pc) bits.push('signaling…'); + else if (peer.pc.connectionState !== 'connected') bits.push(`${peer.pc.connectionState}…`); + else if (!peer.micStream && !peer.state.muted) bits.push('no audio yet'); + if (peer.state.muted) bits.push('muted'); + if (peer.state.sharing) bits.push('sharing'); + sub.innerHTML = bits.join(' ') || 'connected'; + const vol = document.createElement('input'); + vol.type = 'range'; vol.min = 0; vol.max = 2; vol.step = 0.05; vol.value = peer.vol; + vol.title = 'Volume'; + vol.oninput = () => { + peer.vol = Number(vol.value); + if (peer.gain && !S.deafened) peer.gain.gain.value = peer.vol; + }; + tile.append(av, name, sub, vol); + els.tiles.appendChild(tile); + } + renderStageEmpty(); +} + +function renderScreens() { + els.screens.innerHTML = ''; + const shares = []; + if (S.sharing && S.screenStream) shares.push({ name: `${S.name} (you)`, stream: S.screenStream }); + for (const peer of S.peers.values()) { + if (peer.screenStream) shares.push({ name: peer.name, stream: peer.screenStream }); + } + for (const share of shares) { + const card = document.createElement('div'); + card.className = 'screen-card'; + const video = document.createElement('video'); + video.autoplay = true; + video.playsInline = true; + video.muted = false; + video.srcObject = share.stream; + video.play().catch(() => {}); + const label = document.createElement('div'); + label.className = 'label'; + label.innerHTML = '●'; + label.appendChild(document.createTextNode(share.name)); + card.append(video, label); + els.screens.appendChild(card); + } +} + +// --------------------------------------------------------------------------- +// spatial canvas +// --------------------------------------------------------------------------- +const ctx2d = els.spatial.getContext('2d'); +let dragging = null; + +function spatialMetrics() { + const r = els.spatial.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + if (els.spatial.width !== r.width * dpr) { + els.spatial.width = r.width * dpr; + els.spatial.height = r.height * dpr; + } + ctx2d.setTransform(dpr, 0, 0, dpr, 0, 0); + const pad = 30; + return { w: r.width, h: r.height, pad, + toPx: (x, y) => [pad + (x / ROOM_UNITS) * (r.width - 2 * pad), + pad + (y / ROOM_UNITS) * (r.height - 2 * pad)], + fromPx: (px, py) => [((px - pad) / (r.width - 2 * pad)) * ROOM_UNITS, + ((py - pad) / (r.height - 2 * pad)) * ROOM_UNITS] }; +} + +function drawSpatial() { + const m = spatialMetrics(); + ctx2d.clearRect(0, 0, m.w, m.h); + + // grid + ctx2d.strokeStyle = 'rgba(255,255,255,.05)'; + ctx2d.lineWidth = 1; + for (let i = 0; i <= ROOM_UNITS; i += 10) { + const [gx, gy] = m.toPx(i, i); + ctx2d.beginPath(); ctx2d.moveTo(gx, m.toPx(0, 0)[1]); ctx2d.lineTo(gx, m.toPx(0, 100)[1]); ctx2d.stroke(); + ctx2d.beginPath(); ctx2d.moveTo(m.toPx(0, 0)[0], gy); ctx2d.lineTo(m.toPx(100, 0)[0], gy); ctx2d.stroke(); + } + + // audible range around me (~gain 0.05 at 56 units with ref=4, rolloff=1.4) + const [mx, my] = m.toPx(S.me.x, S.me.y); + const rangeR = (56 / ROOM_UNITS) * (m.w - 2 * m.pad); + ctx2d.strokeStyle = 'rgba(88,101,242,.35)'; + ctx2d.setLineDash([5, 5]); + ctx2d.beginPath(); ctx2d.arc(mx, my, Math.max(0, Math.min(rangeR, m.h - 2 * m.pad)), 0, Math.PI * 2); ctx2d.stroke(); + ctx2d.setLineDash([]); + + // peers + for (const peer of S.peers.values()) drawAvatarDot(peer.name, peer.id, peer.x, peer.y, peer.speaking, peer.state.muted, false, m); + // me (draggable listener) + drawAvatarDot(S.name || 'me', S.myId || 'me', S.me.x, S.me.y, els.meAvatar.classList.contains('speaking'), S.muted, true, m); + + if (!dragging) requestAnimationFrame(drawSpatial); +} +requestAnimationFrame(drawSpatial); + +function drawAvatarDot(name, id, x, y, speaking, muted, isMe, m) { + const [px, py] = m.toPx(x, y); + const r = isMe ? 16 : 13; + ctx2d.beginPath(); + ctx2d.arc(px, py, r, 0, Math.PI * 2); + ctx2d.fillStyle = colorFor(id); + ctx2d.fill(); + if (speaking) { + ctx2d.strokeStyle = '#23a55a'; + ctx2d.lineWidth = 3; + ctx2d.stroke(); + } + if (muted) { + ctx2d.strokeStyle = '#f23f43'; + ctx2d.lineWidth = 3; + ctx2d.beginPath(); + ctx2d.moveTo(px - r, py + r); + ctx2d.lineTo(px + r, py - r); + ctx2d.stroke(); + } + ctx2d.fillStyle = '#fff'; + ctx2d.font = `bold ${Math.round(r)}px system-ui`; + ctx2d.textAlign = 'center'; + ctx2d.textBaseline = 'middle'; + ctx2d.fillText(name.slice(0, 1).toUpperCase(), px, py + 1); + ctx2d.font = '11px system-ui'; + ctx2d.fillStyle = isMe ? '#b5bac1' : '#80848e'; + ctx2d.fillText(name + (isMe ? ' (you)' : ''), px, py + r + 12); +} + +els.spatial.addEventListener('pointerdown', (ev) => { + const m = spatialMetrics(); + const rect = els.spatial.getBoundingClientRect(); + const px = ev.clientX - rect.left, py = ev.clientY - rect.top; + const [ux, uy] = m.fromPx(px, py); + const d = Math.hypot(ux - S.me.x, uy - S.me.y); + if (d < 10) { + dragging = { dx: S.me.x - ux, dy: S.me.y - uy }; + els.spatial.classList.add('dragging'); + els.spatial.setPointerCapture(ev.pointerId); + } +}); +els.spatial.addEventListener('pointermove', (ev) => { + if (!dragging) return; + const m = spatialMetrics(); + const rect = els.spatial.getBoundingClientRect(); + const [ux, uy] = m.fromPx(ev.clientX - rect.left, ev.clientY - rect.top); + S.me.x = Math.min(ROOM_UNITS, Math.max(0, ux + dragging.dx)); + S.me.y = Math.min(ROOM_UNITS, Math.max(0, uy + dragging.dy)); + sendMyPos(); +}); +els.spatial.addEventListener('pointerup', () => { + if (!dragging) return; + dragging = null; + els.spatial.classList.remove('dragging'); + sendMyPos(true); + requestAnimationFrame(drawSpatial); // rAF loop stopped while dragging +}); + +// --------------------------------------------------------------------------- +// controls & wiring +// --------------------------------------------------------------------------- +function updateMicButton() { + els.mic.classList.toggle('off', S.muted || !S.micStream); +} +function updateDeafenButton() { + els.deafen.classList.toggle('off', S.deafened); +} +function sendState() { + send({ type: 'state', state: { muted: S.muted, deafened: S.deafened, sharing: S.sharing } }); +} + +function toggleMic() { + if (!S.micStream) return; + S.muted = !S.muted; + S.micStream.getAudioTracks().forEach((t) => (t.enabled = !S.muted)); + updateMicButton(); + sendState(); + renderTiles(); +} + +function toggleDeafen() { + if (!S.audioCtx) return; + S.deafened = !S.deafened; + for (const peer of S.peers.values()) { + if (peer.gain) peer.gain.gain.value = S.deafened ? 0 : peer.vol; + } + updateDeafenButton(); + sendState(); + renderTiles(); +} + +els.mic.onclick = toggleMic; +els.deafen.onclick = toggleDeafen; +$('#btn-settings').onclick = openSettings; + +els.nick.value = S.name; +els.nick.addEventListener('change', () => { + S.name = els.nick.value.trim() || `guest-${Math.random().toString(36).slice(2, 6)}`; + els.nick.value = S.name; + localStorage.setItem(LS_NAME, S.name); + setAvatar(els.meAvatar, S.name, S.myId || 'me'); +}); +if (!S.name) { + S.name = `guest-${Math.random().toString(36).slice(2, 6)}`; + els.nick.value = S.name; + localStorage.setItem(LS_NAME, S.name); +} +setAvatar(els.meAvatar, S.name, 'me'); + +document.querySelectorAll('.channel.voice').forEach((el) => { + el.onclick = () => { + if (S.room === el.dataset.room) return; + document.querySelectorAll('.channel.voice').forEach((c) => c.classList.remove('active')); + el.classList.add('active'); + joinVoice(el.dataset.room).catch((e) => { + console.error(e); + toast(`Join failed: ${e.message}`, 'error'); + leaveVoice('join failed'); + }); + }; +}); +document.querySelectorAll('.channel.text').forEach((el) => { + el.onclick = () => toast('POC: text chat is not implemented'); +}); + +// spawn me somewhere spread out, not dead-center on top of others +(() => { + let h = 0; + for (const c of S.myId || S.name) h = (h * 33 + c.charCodeAt(0)) >>> 0; + const a = (h % 360) * (Math.PI / 180); + S.me.x = 50 + 20 * Math.cos(a); + S.me.y = 50 + 20 * Math.sin(a); +})(); + +window.addEventListener('keydown', (ev) => { + if (ev.target.tagName === 'INPUT' || ev.target.tagName === 'TEXTAREA') return; + if (ev.key === 'm') toggleMic(); + if (ev.key === 'd') toggleDeafen(); +}); +window.addEventListener('beforeunload', () => { try { leaveVoice(); } catch {} }); + +// --------------------------------------------------------------------------- +// settings modal (custom STUN/TURN) +// --------------------------------------------------------------------------- +function openSettings() { + const override = localStorage.getItem(LS_ICE); + $('#override-enabled').checked = !!override; + const current = override ? JSON.parse(override) : effectiveIce(); + $('#ice-json').value = JSON.stringify(current, null, 2); + fetchIce().then( + (server) => { $('#ice-server-default').textContent = JSON.stringify(server, null, 2); }, + () => { $('#ice-server-default').textContent = '(unavailable)'; }, + ); + $('#modal-backdrop').classList.remove('hidden'); +} +$('#ice-cancel').onclick = () => $('#modal-backdrop').classList.add('hidden'); +$('#modal-backdrop').onclick = (ev) => { + if (ev.target === $('#modal-backdrop')) $('#modal-backdrop').classList.add('hidden'); +}; +$('#ice-reset').onclick = () => { + localStorage.removeItem(LS_ICE); + $('#override-enabled').checked = false; + fetchIce().then((server) => { $('#ice-json').value = JSON.stringify(server, null, 2); }); +}; +$('#ice-save').onclick = () => { + const enabled = $('#override-enabled').checked; + let parsed = null; + if (enabled) { + try { + parsed = JSON.parse($('#ice-json').value); + if (!Array.isArray(parsed.iceServers)) throw new Error('missing "iceServers" array'); + } catch (err) { + toast(`Invalid ICE JSON: ${err.message}`, 'error'); + return; + } + localStorage.setItem(LS_ICE, JSON.stringify(parsed)); + } else { + localStorage.removeItem(LS_ICE); + } + $('#modal-backdrop').classList.add('hidden'); + toast(enabled ? 'Custom ICE saved — rejoin to apply' : 'Reverted to server ICE — rejoin to apply', 'ok'); +}; diff --git a/voice-poc/public/index.html b/voice-poc/public/index.html new file mode 100644 index 0000000..f264cbf --- /dev/null +++ b/voice-poc/public/index.html @@ -0,0 +1,92 @@ + + + + + +gibbz voice — POC + + + +
+ + + + + + + +
+
+
+
+
+
🔊
+

Pick a voice channel to join

+

Peer-to-peer WebRTC voice & screen sharing. Drag avatars on the spatial plane to position who you hear.

+
+
+ +
+
+ SPATIAL AUDIO + drag your avatar — others hear you shift; you hear them relative to position +
+ +
+
+
+ + + + +
+ + + + diff --git a/voice-poc/public/style.css b/voice-poc/public/style.css new file mode 100644 index 0000000..c5bec25 --- /dev/null +++ b/voice-poc/public/style.css @@ -0,0 +1,204 @@ +/* Discord-ish dark theme (POC) */ +:root { + --bg-0: #1e1f22; /* rail */ + --bg-1: #2b2d31; /* sidebar */ + --bg-2: #313338; /* main */ + --bg-3: #383a40; /* hover */ + --bg-4: #404249; + --text-0: #f2f3f5; + --text-1: #b5bac1; + --text-2: #80848e; + --brand: #5865f2; + --green: #23a55a; + --red: #f23f43; + --yellow: #f0b232; + --radius: 8px; +} +* { box-sizing: border-box; margin: 0; } +html, body { height: 100%; } +body { + font: 14px/1.4 "gg sans", "Segoe UI", system-ui, -apple-system, sans-serif; + background: var(--bg-2); + color: var(--text-0); + overflow: hidden; +} +#app { display: flex; height: 100vh; } + +/* rail */ +#rail { + width: 68px; background: var(--bg-0); padding: 12px 0; + display: flex; flex-direction: column; align-items: center; gap: 8px; flex-shrink: 0; +} +.rail-icon { + width: 44px; height: 44px; border-radius: 50%; background: var(--bg-3); + display: grid; place-items: center; font-weight: 700; color: var(--text-1); + cursor: pointer; transition: border-radius .15s, background .15s; +} +.rail-icon:hover, .rail-icon.home { border-radius: 16px; background: var(--brand); color: #fff; } +.rail-sep { width: 28px; height: 2px; background: var(--bg-3); border-radius: 1px; } + +/* sidebar */ +#sidebar { + width: 250px; background: var(--bg-1); display: flex; flex-direction: column; flex-shrink: 0; +} +#server-name { + padding: 14px 16px; font-weight: 700; font-size: 15px; + border-bottom: 1px solid rgba(0,0,0,.25); box-shadow: 0 1px 0 rgba(0,0,0,.06); +} +.poc-tag { + font-size: 10px; background: var(--yellow); color: #000; border-radius: 4px; + padding: 1px 5px; vertical-align: 2px; margin-left: 6px; font-weight: 800; +} +.channel-group-label { + padding: 18px 8px 4px 16px; font-size: 11px; font-weight: 700; + letter-spacing: .4px; color: var(--text-2); text-transform: uppercase; +} +.channel { + margin: 1px 8px; padding: 7px 8px; border-radius: 6px; color: var(--text-1); + cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.channel:hover { background: var(--bg-3); color: var(--text-0); } +.channel.text { cursor: not-allowed; color: var(--text-2); } +.channel.text .hint { font-size: 10px; margin-left: 4px; } +.channel.voice.active { background: rgba(88,101,242,.25); color: var(--text-0); } +.channel .count { float: right; color: var(--text-2); font-size: 12px; } + +#user-panel { + margin-top: auto; background: var(--bg-0); padding: 8px; + display: flex; align-items: center; gap: 8px; +} +.me { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; } +.me-meta { display: flex; flex-direction: column; min-width: 0; } +#nick { + background: transparent; border: none; color: var(--text-0); font: inherit; + font-weight: 600; width: 100%; padding: 0; outline: none; border-bottom: 1px dashed transparent; +} +#nick:hover, #nick:focus { border-bottom-color: var(--text-2); } +#voice-status { font-size: 11px; color: var(--text-2); } +.user-buttons { display: flex; gap: 2px; } +.ctl { + background: transparent; border: none; color: var(--text-1); font-size: 16px; + width: 32px; height: 32px; border-radius: 6px; cursor: pointer; position: relative; +} +.ctl:hover { background: var(--bg-3); color: var(--text-0); } +.ctl.off { color: var(--red); } +.ctl.off::after { + content: ""; position: absolute; left: 7px; top: 15px; width: 18px; height: 2px; + background: var(--red); transform: rotate(-40deg); border-radius: 1px; +} +.ctl:disabled { opacity: .35; cursor: not-allowed; } + +/* main */ +#main { flex: 1; display: flex; flex-direction: column; min-width: 0; } +#stage { + flex: 1; display: flex; flex-direction: column; padding: 16px; gap: 16px; + overflow: auto; position: relative; +} +#stage-empty { + position: absolute; inset: 0; display: grid; place-content: center; + text-align: center; gap: 8px; color: var(--text-1); padding: 24px; +} +#stage-empty .big-icon { font-size: 56px; } +#stage-empty h2 { font-size: 20px; color: var(--text-0); } +#stage-empty p { max-width: 420px; color: var(--text-2); } +#stage.empty #stage-empty { display: grid; } +#stage:not(.empty) #stage-empty { display: none; } + +#screens { display: flex; gap: 12px; flex-wrap: wrap; } +.screen-card { + position: relative; background: #000; border-radius: var(--radius); overflow: hidden; + flex: 1 1 480px; max-width: 100%; box-shadow: 0 8px 24px rgba(0,0,0,.4); +} +.screen-card video { width: 100%; display: block; max-height: 62vh; object-fit: contain; } +.screen-card .label { + position: absolute; left: 8px; bottom: 8px; background: rgba(0,0,0,.65); + padding: 3px 8px; border-radius: 5px; font-size: 12px; font-weight: 600; +} +.screen-card .label .live { color: var(--red); font-weight: 800; margin-right: 5px; } + +#tiles { display: flex; gap: 10px; flex-wrap: wrap; } +.tile { + background: var(--bg-1); border-radius: var(--radius); padding: 12px; width: 150px; + display: flex; flex-direction: column; align-items: center; gap: 6px; position: relative; + border: 2px solid transparent; +} +.tile.speaking { border-color: var(--green); } +.tile .avatar { width: 56px; height: 56px; font-size: 24px; } +.tile .name { font-weight: 600; max-width: 100%; overflow: hidden; text-overflow: ellipsis; } +.tile .sub { font-size: 11px; color: var(--text-2); display: flex; gap: 6px; align-items: center; } +.tile .mute-tag { color: var(--red); } +.tile .share-tag { color: var(--yellow); } +.tile input[type=range] { width: 100%; accent-color: var(--brand); } +.tile .connecting { font-size: 11px; color: var(--yellow); } + +.avatar { + width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center; + font-weight: 700; color: #fff; flex-shrink: 0; user-select: none; +} + +/* spatial plane */ +#spatial-wrap { + height: 34vh; min-height: 220px; background: var(--bg-1); + border-top: 1px solid rgba(0,0,0,.35); display: flex; flex-direction: column; +} +.spatial-label { + padding: 8px 16px 0; font-size: 11px; font-weight: 700; color: var(--text-2); + letter-spacing: .5px; display: flex; gap: 16px; +} +.spatial-label .hint { font-weight: 400; letter-spacing: 0; text-transform: none; } +#spatial { flex: 1; width: 100%; cursor: grab; touch-action: none; } +#spatial.dragging { cursor: grabbing; } + +/* modal */ +#modal-backdrop { + position: fixed; inset: 0; background: rgba(0,0,0,.7); display: grid; + place-items: center; z-index: 50; +} +#modal-backdrop.hidden { display: none; } +#settings-modal { + width: min(620px, 92vw); max-height: 90vh; overflow: auto; + background: var(--bg-1); border-radius: 8px; padding: 20px; + box-shadow: 0 12px 40px rgba(0,0,0,.6); +} +#settings-modal h2 { font-size: 18px; margin-bottom: 6px; } +#settings-modal h3 { font-size: 13px; margin: 16px 0 4px; color: var(--text-1); } +#settings-modal p { margin-bottom: 10px; } +#ice-json { + 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; + resize: vertical; +} +#ice-json:focus { outline: none; border-color: var(--brand); } +#ice-server-default { + background: var(--bg-0); padding: 8px 10px; border-radius: 6px; + font: 11px/1.5 ui-monospace, monospace; white-space: pre-wrap; +} +.toggle-row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; cursor: pointer; } +.toggle-row input { accent-color: var(--brand); width: 16px; height: 16px; } +.modal-actions { display: flex; gap: 8px; margin-top: 12px; align-items: center; } +.modal-actions .spacer { flex: 1; } +.modal-actions button { + padding: 8px 16px; border-radius: 6px; border: none; cursor: pointer; + font-weight: 600; font-size: 13px; +} +.modal-actions button:not(.ghost) { background: var(--brand); color: #fff; } +.modal-actions button.ghost { background: transparent; color: var(--text-1); } +.modal-actions button.ghost:hover { text-decoration: underline; } + +/* toasts */ +#toasts { + position: fixed; bottom: 16px; right: 16px; display: flex; flex-direction: column; + gap: 8px; z-index: 60; max-width: 380px; +} +.toast { + background: var(--bg-0); border-left: 3px solid var(--brand); border-radius: 6px; + padding: 10px 14px; box-shadow: 0 8px 20px rgba(0,0,0,.5); font-size: 13px; + animation: slide-in .18s ease-out; +} +.toast.error { border-left-color: var(--red); } +.toast.warn { border-left-color: var(--yellow); } +.toast.ok { border-left-color: var(--green); } +@keyframes slide-in { from { transform: translateX(30px); opacity: 0; } } + +.hint { color: var(--text-2); font-size: 12px; } +code { font-family: ui-monospace, monospace; font-size: 12px; } diff --git a/voice-poc/server.js b/voice-poc/server.js new file mode 100644 index 0000000..b4d56c7 --- /dev/null +++ b/voice-poc/server.js @@ -0,0 +1,199 @@ +// Minimal WebRTC signaling + static file server for the Discord-clone POC. +// POC shortcut: no auth, no persistence, in-memory rooms, no rate limiting. +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { WebSocketServer } = require('ws'); + +const PORT = Number(process.env.PORT || 8080); +const HOST = process.env.HOST || '0.0.0.0'; +const PUBLIC_DIR = 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_SERVERS_JSON complete {"iceServers":[...]} override +// --------------------------------------------------------------------------- +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 iceServers = urls(process.env.STUN_URLS || 'stun:stun.l.google.com:19302,stun:stun1.l.google.com:19302') + .map((u) => ({ urls: u })); + const turnUrls = urls(process.env.TURN_URLS); + if (turnUrls.length) { + iceServers.push({ + urls: turnUrls, + username: process.env.TURN_USERNAME || '', + credential: process.env.TURN_CREDENTIAL || process.env.TURN_PASSWORD || '', + }); + } + return { iceServers }; +} + +// --------------------------------------------------------------------------- +// 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', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.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(PUBLIC_DIR, rel)); + if (!file.startsWith(PUBLIC_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) => { + if (req.url.startsWith('/api/ice')) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(iceConfig())); + return; + } + serveStatic(req, res); +}); + +// --------------------------------------------------------------------------- +// 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); + } +} + +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); + 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 '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); + break; + } + case 'offer': + case 'answer': + case 'candidate': { + 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 }); + 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)); + +server.listen(PORT, HOST, () => { + const ice = iceConfig(); + console.log(`voice-poc listening on http://${HOST}:${PORT}`); + console.log(`ICE servers: ${JSON.stringify(ice.iceServers)}`); +});