class VoiceChannel { constructor(ws, settings) { this.ws = ws; this.settings = settings || { mode: 'vox', pttKey: 'v', voxThreshold: 0.1 }; this.localStream = null; this.screenStream = null; this.peers = {}; // userId -> RTCPeerConnection this.participants = {}; // userId -> {username, avatarUrl} this.currentChannelId = null; this.isScreenSharing = false; this.audioContext = null; this.analyser = null; this.microphone = null; this.scriptProcessor = null; this.isTalking = false; this.pttPressed = false; this.voxActive = false; this.lastVoiceTime = 0; this.voxHoldTime = 500; // ms to keep open after sound drops below threshold this.setupPTTListeners(); window.addEventListener('beforeunload', () => this.leave()); } setupPTTListeners() { window.addEventListener('keydown', (e) => { if (this.settings.mode === 'ptt' && e.key.toLowerCase() === this.settings.pttKey.toLowerCase()) { if (!this.pttPressed) { this.pttPressed = true; this.updateMuteState(); } } }); window.addEventListener('keyup', (e) => { if (this.settings.mode === 'ptt' && e.key.toLowerCase() === this.settings.pttKey.toLowerCase()) { this.pttPressed = false; this.updateMuteState(); } }); } async join(channelId) { console.log('VoiceChannel.join called for channel:', channelId); if (this.currentChannelId === channelId) { console.log('Already in this channel'); return; } if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { console.error('WebSocket not connected. State:', this.ws ? this.ws.readyState : 'null'); alert('Unable to join voice: Connection to signaling server not established. Please wait a few seconds and try again.'); return; } if (this.currentChannelId) this.leave(); console.log('Joining voice channel:', channelId); this.currentChannelId = channelId; try { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { throw new Error('Microphone access is only available on secure origins (HTTPS).'); } this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); // Start muted this.setMute(true); if (this.settings.mode === 'vox') { this.setupVOX(); } // Persist in DB const fd = new FormData(); fd.append('action', 'join'); fd.append('channel_id', channelId); fetch('api_v1_voice.php', { method: 'POST', body: fd }); this.ws.send(JSON.stringify({ type: 'voice_join', channel_id: channelId, user_id: window.currentUserId, username: window.currentUsername, avatar_url: window.currentAvatarUrl })); this.updateVoiceUI(); } catch (e) { console.error('Failed to get local stream:', e); alert('Could not access microphone.'); this.currentChannelId = null; } } setupVOX() { if (this.audioContext) this.audioContext.close(); this.audioContext = new (window.AudioContext || window.webkitAudioContext)(); this.analyser = this.audioContext.createAnalyser(); this.microphone = this.audioContext.createMediaStreamSource(this.localStream); this.scriptProcessor = this.audioContext.createScriptProcessor(2048, 1, 1); this.analyser.smoothingTimeConstant = 0.8; this.analyser.fftSize = 1024; this.microphone.connect(this.analyser); this.analyser.connect(this.scriptProcessor); this.scriptProcessor.connect(this.audioContext.destination); this.scriptProcessor.onaudioprocess = () => { const array = new Uint8Array(this.analyser.frequencyBinCount); this.analyser.getByteFrequencyData(array); let values = 0; for (let i = 0; i < array.length; i++) { values += array[i]; } const average = values / array.length; const normalized = average / 128; // 0 to 2 approx if (normalized > this.settings.voxThreshold) { this.lastVoiceTime = Date.now(); if (!this.voxActive) { this.voxActive = true; this.updateMuteState(); } } else { if (this.voxActive && Date.now() - this.lastVoiceTime > this.voxHoldTime) { this.voxActive = false; this.updateMuteState(); } } }; } updateMuteState() { if (!this.currentChannelId || !this.localStream) return; let shouldTalk = false; if (this.settings.mode === 'ptt') { shouldTalk = this.pttPressed; } else { shouldTalk = this.voxActive; } if (this.isTalking !== shouldTalk) { this.isTalking = shouldTalk; this.setMute(!shouldTalk); // Notify others this.ws.send(JSON.stringify({ type: 'voice_speaking', channel_id: this.currentChannelId, user_id: window.currentUserId, speaking: shouldTalk })); this.updateSpeakingUI(window.currentUserId, shouldTalk); } } setMute(mute) { if (this.localStream) { this.localStream.getAudioTracks().forEach(track => { track.enabled = !mute; }); } } leave() { if (!this.currentChannelId) return; this.stopScreenShare(); // Persist in DB const fd = new FormData(); fd.append('action', 'leave'); fetch('api_v1_voice.php', { method: 'POST', body: fd }); this.ws.send(JSON.stringify({ type: 'voice_leave', channel_id: this.currentChannelId, user_id: window.currentUserId })); if (this.localStream) { this.localStream.getTracks().forEach(track => track.stop()); this.localStream = null; } if (this.audioContext) { this.audioContext.close(); this.audioContext = null; } Object.values(this.peers).forEach(pc => pc.close()); this.peers = {}; this.participants = {}; this.currentChannelId = null; this.isTalking = false; this.updateVoiceUI(); } async handleSignaling(data) { const { type, from, to, offer, answer, candidate, channel_id, username, avatar_url, speaking } = data; if (channel_id != this.currentChannelId) return; if (to && to != window.currentUserId) return; switch (type) { case 'voice_join': if (from != window.currentUserId) { this.participants[from] = { username: username || `User ${from}`, avatar_url: avatar_url }; this.createPeerConnection(from, true); this.updateVoiceUI(); } break; case 'voice_offer': this.participants[from] = { username: username || `User ${from}`, avatar_url: avatar_url }; await this.handleOffer(from, offer); this.updateVoiceUI(); break; case 'voice_answer': await this.handleAnswer(from, answer); break; case 'voice_ice_candidate': await this.handleCandidate(from, candidate); break; case 'voice_speaking': this.updateSpeakingUI(from, speaking); break; case 'voice_leave': if (this.peers[from]) { this.peers[from].close(); delete this.peers[from]; } delete this.participants[from]; this.updateVoiceUI(); break; } } createPeerConnection(userId, isOfferor) { if (this.peers[userId]) return this.peers[userId]; const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }); this.peers[userId] = pc; if (this.localStream) { this.localStream.getTracks().forEach(track => { pc.addTrack(track, this.localStream); }); } pc.onicecandidate = (event) => { if (event.candidate) { this.ws.send(JSON.stringify({ type: 'voice_ice_candidate', to: userId, from: window.currentUserId, candidate: event.candidate, channel_id: this.currentChannelId })); } }; pc.ontrack = (event) => { if (event.track.kind === 'audio') { const remoteAudio = new Audio(); remoteAudio.srcObject = event.streams[0]; remoteAudio.play(); } }; if (isOfferor) { pc.createOffer().then(offer => { return pc.setLocalDescription(offer); }).then(() => { this.ws.send(JSON.stringify({ type: 'voice_offer', to: userId, from: window.currentUserId, username: window.currentUsername, avatar_url: window.currentAvatarUrl, offer: pc.localDescription, channel_id: this.currentChannelId })); }); } return pc; } async handleOffer(from, offer) { const pc = this.createPeerConnection(from, false); await pc.setRemoteDescription(new RTCSessionDescription(offer)); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); this.ws.send(JSON.stringify({ type: 'voice_answer', to: from, from: window.currentUserId, answer: pc.localDescription, channel_id: this.currentChannelId })); } async handleAnswer(from, answer) { const pc = this.peers[from]; if (pc) await pc.setRemoteDescription(new RTCSessionDescription(answer)); } async handleCandidate(from, candidate) { const pc = this.peers[from]; if (pc) await pc.addIceCandidate(new RTCIceCandidate(candidate)); } updateVoiceUI() { document.querySelectorAll('.voice-users-list').forEach(el => el.innerHTML = ''); // Fetch all sessions to update all channels (or just rely on signaling for current one) // For simplicity, we update the current channel from participants if (this.currentChannelId) { const listEls = document.querySelectorAll(`.voice-item[data-channel-id="${this.currentChannelId}"] + .voice-users-list`); listEls.forEach(listEl => { // Me this.addVoiceUserToUI(listEl, window.currentUserId, window.currentUsername, window.currentAvatarUrl); // Others Object.entries(this.participants).forEach(([uid, data]) => { this.addVoiceUserToUI(listEl, uid, data.username, data.avatar_url); }); }); // Voice controls if (!document.querySelector('.voice-controls')) { const controls = document.createElement('div'); controls.className = 'voice-controls p-2 d-flex justify-content-between align-items-center border-top bg-dark'; controls.style.backgroundColor = '#232428'; controls.innerHTML = `