1- // core/voiceClient.js
2- //
3- // Persistent, app-wide voice/screen-share client built on PeerJS. Lives for
4- // the lifetime of the app (not the component), so a call survives navigating
5- // to other channels/servers.
6- //
7- // P2P layer: PeerJS. `peer_id` from voice_join/voice_user_joined is a PeerJS
8- // broker id — offer/answer/ICE are handled entirely by PeerJS's own broker,
9- // not relayed over the app's websocket. The chat websocket (`conn`) is only
10- // used for the voice_* room-membership commands.
11- //
12- // Each pair of participants gets one bidirectional audio call, plus a
13- // separate one-way call per active screen share (tagged via call metadata
14- // so we can tell streams apart on the receiving end). Camera is left out
15- // for now — same mechanism, add a "camera" kind alongside "screen" later.
161
172import { createStore , produce } from "solid-js/store" ;
183import { createSignal , createEffect , on } from "solid-js" ;
@@ -27,24 +12,22 @@ const RETRY_CAP_MS = 20_000;
2712const JOIN_FALLBACK_MS = 1_500 ;
2813const PEER_TIMEOUT_MS = 30_000 ;
2914const MONITOR_INTERVAL_MS = 5_000 ;
30- const SPEAKING_THRESHOLD = 6 ; // 0-100 scale from analyser average
15+ const SPEAKING_THRESHOLD = 6 ;
3116
3217const STORAGE_VOLUMES = "voice_userVolumes" ;
3318const STORAGE_MUTES = "voice_userMutes" ;
3419
3520export const [ voice , setVoice ] = createStore ( {
36- channel : null , // string | null
37- server : null , // server src this call belongs to
21+ channel : null ,
22+ server : null ,
3823 joining : false ,
3924 error : null ,
4025 muted : false ,
4126 deafened : false ,
42- speaking : false , // am I speaking
27+ speaking : false ,
4328 myPeerId : null ,
4429 isScreenSharing : false ,
45- // peer_id -> { username, peerId, muted, speaking, callState, locallyMuted, localVolume }
4630 participants : { } ,
47- // peer_id -> MediaStream, for anyone currently sharing their screen
4831 screenStreams : { } ,
4932} ) ;
5033
@@ -64,9 +47,8 @@ const audioEls = new Map();
6447/** @type {Map<string, object> } */
6548const peerEntries = new Map ( ) ;
6649
67- let localDetector = null ; // { source, analyser, frameId }
68- const remoteDetectors = new Map ( ) ; // peer_id -> { source, analyser, frameId }
69-
50+ let localDetector = null ;
51+ const remoteDetectors = new Map ( ) ;
7052let monitorTimer = null ;
7153
7254let userVolumes = loadMap ( STORAGE_VOLUMES ) ;
@@ -83,7 +65,6 @@ function saveMap(key, data) {
8365 try {
8466 localStorage . setItem ( key , JSON . stringify ( data ) ) ;
8567 } catch {
86- /* ignore (private mode, etc.) */
8768 }
8869}
8970
@@ -124,7 +105,6 @@ function setParticipant(peerId, patch) {
124105 ) ;
125106}
126107
127- // ── local audio playback for remote mics ──────────────────────────────────
128108
129109function playRemoteAudio ( peerId , stream ) {
130110 let el = audioEls . get ( peerId ) ;
@@ -151,7 +131,7 @@ export function setUserVolume(peerId, volume) {
151131 userVolumes [ peerId ] = clamped ;
152132 saveMap ( STORAGE_VOLUMES , userVolumes ) ;
153133 const el = audioEls . get ( peerId ) ;
154- if ( el ) el . volume = Math . min ( 1 , clamped ) ; // <audio>.volume caps at 1; swap in a GainNode if you need boost above unity
134+ if ( el ) el . volume = Math . min ( 1 , clamped ) ;
155135 setParticipant ( peerId , { localVolume : clamped } ) ;
156136}
157137export function getUserVolume ( peerId ) {
@@ -168,7 +148,6 @@ export function getUserMuted(peerId) {
168148 return userMutes [ peerId ] ?? false ;
169149}
170150
171- // ── speaking detection ─────────────────────────────────────────────────────
172151
173152function startLocalSpeakingDetection ( ) {
174153 if ( ! localAudioStream ) return ;
@@ -241,7 +220,6 @@ function stopRemoteSpeakingDetection(peerId) {
241220 }
242221}
243222
244- // ── retry helpers ──────────────────────────────────────────────────────────
245223
246224function scheduleRetry ( retry , fn , max = AUDIO_RETRY_MAX ) {
247225 if ( retry . count >= max ) {
@@ -265,9 +243,6 @@ function clearRetry(retry) {
265243 }
266244}
267245
268- // ── audio mesh ──────────────────────────────────────────────────────────────
269-
270- /** Lexicographically-smaller peer id initiates, to avoid both sides calling at once. */
271246function isAudioInitiator ( remotePeerId ) {
272247 return ! ! peer ?. id && peer . id < remotePeerId ;
273248}
@@ -280,8 +255,6 @@ function ensureAudio(remotePeerId) {
280255 callPeerAudio ( remotePeerId ) ;
281256 return ;
282257 }
283- // Otherwise wait for their inbound call; fall back to initiating ourselves
284- // if it never arrives (handles their initial attempt getting lost).
285258 if ( entry . joinFallbackTimer !== null ) clearTimeout ( entry . joinFallbackTimer ) ;
286259 entry . joinFallbackTimer = setTimeout ( ( ) => {
287260 entry . joinFallbackTimer = null ;
@@ -332,8 +305,6 @@ function callPeerAudio(remotePeerId) {
332305 } ) ;
333306}
334307
335- // ── screen share mesh ───────────────────────────────────────────────────────
336-
337308function callPeerScreen ( remotePeerId , stream ) {
338309 if ( ! peer || peer . destroyed ) return ;
339310 const tracks = stream . getVideoTracks ( ) ;
@@ -394,7 +365,6 @@ function closeScreenCalls() {
394365 }
395366}
396367
397- /** Start/stop sharing your screen with everyone currently in the call. */
398368export async function toggleScreenShare ( ) {
399369 if ( localScreenStreamRaw ) {
400370 stopScreenShare ( ) ;
@@ -406,7 +376,6 @@ export async function toggleScreenShare() {
406376 audio : true ,
407377 } ) ;
408378 } catch {
409- // cancelled or denied by the browser's picker — not worth surfacing as an error
410379 return ;
411380 }
412381 const track = localScreenStreamRaw . getVideoTracks ( ) [ 0 ] ;
@@ -425,8 +394,6 @@ function stopScreenShare() {
425394 closeScreenCalls ( ) ;
426395}
427396
428- // ── inbound calls ───────────────────────────────────────────────────────────
429-
430397function handleInboundCall ( call ) {
431398 if ( ! voice . channel ) return ;
432399 const kind = call . metadata ?. kind === "screen" ? "screen" : "audio" ;
@@ -473,8 +440,6 @@ function handleInboundCall(call) {
473440 } ) ;
474441}
475442
476- // ── connection state watcher + monitor ──────────────────────────────────────
477-
478443function watchConnectionState ( call , remotePeerId , entry , attempt = 0 ) {
479444 const pc = call . peerConnection ;
480445 if ( ! pc ) {
@@ -558,15 +523,13 @@ function detachPeer(peerId) {
558523 ) ;
559524}
560525
561- // ── peer lifecycle ──────────────────────────────────────────────────────────
562-
563526function initPeer ( ) {
564527 if ( peer && ! peer . destroyed && peerReady ) return peerReady ;
565528 peerReady = new Promise ( ( resolve , reject ) => {
566529 const p = new Peer ( {
567530 debug : 0 ,
568531 config : { iceServers : ICE_SERVERS , iceCandidatePoolSize : 4 } ,
569- } ) ; // pass {host, port, path, secure} here for a self-hosted PeerServer
532+ } ) ;
570533 p . on ( "open" , ( ) => { peer = p ; resolve ( p ) ; } ) ;
571534 p . on ( "error" , ( err ) => { if ( ! peer ) reject ( err ) ; else console . error ( "[voice] peer error" , err ) ; } ) ;
572535 p . on ( "disconnected" , ( ) => {
@@ -577,7 +540,6 @@ function initPeer() {
577540 return peerReady ;
578541}
579542
580- // ── public API ───────────────────────────────────────────────────────────────
581543
582544export async function joinVoiceChannel ( conn , channel , serverSrc ) {
583545 if ( voice . channel ) leaveVoiceChannel ( ) ;
@@ -645,11 +607,9 @@ export function toggleVoiceDeafen() {
645607 for ( const [ peerId , el ] of audioEls ) {
646608 el . muted = nextDeafened || ! ! userMutes [ peerId ] ;
647609 }
648- // Deafening implies muting yourself too, same convention as Discord.
649610 if ( nextDeafened && ! voice . muted ) toggleVoiceMute ( ) ;
650611}
651612
652- // ── incoming websocket events ────────────────────────────────────────────────
653613
654614function handleVoiceEvent ( event ) {
655615 if ( ! event ?. cmd ?. startsWith ( "voice_" ) ) return ;
@@ -710,8 +670,6 @@ function handleVoiceEvent(event) {
710670 break ;
711671 }
712672 case "voice_mute" : {
713- // Assumed shape: { cmd: "voice_mute", channel, peer_id, muted }.
714- // Adjust if your voice_mute.md doc specifies something different.
715673 if ( event . channel !== voice . channel ) break ;
716674 setParticipant ( event . peer_id , { muted : ! ! event . muted } ) ;
717675 break ;
@@ -721,17 +679,9 @@ function handleVoiceEvent(event) {
721679 }
722680}
723681
724- /**
725- * Call once per active `conn` — e.g. right after `useServerConnection()` in
726- * App.jsx — so voice broadcasts get processed regardless of which
727- * channel/server is currently on screen. Must be called inside a component
728- * or other reactive root (uses createEffect).
729- */
730682export function bindVoiceEvents ( conn ) {
731683 createEffect (
732684 on ( conn . lastEvent , ( event ) => {
733- // Only react to the connection our active call belongs to (or any
734- // connection before we've joined, so we can catch the join response).
735685 if ( voice . channel && activeConn && conn !== activeConn ) return ;
736686 handleVoiceEvent ( event ) ;
737687 } )
0 commit comments