console.log('voice.js loaded'); class VoiceChannel { constructor(ws, settings) { // ws is ignored now as we use PHP signaling, but kept for compatibility this.settings = settings || { mode: 'vox', pttKey: 'v', voxThreshold: 0.1, inputDevice: 'default', outputDevice: 'default', inputVolume: 1.0, outputVolume: 1.0, echoCancellation: true, noiseSuppression: true }; console.log('VoiceChannel constructor called with settings:', this.settings); this.localStream = null; this.analysisStream = null; this.peers = {}; // userId -> RTCPeerConnection this.participants = {}; // userId -> {name} this.currentChannelId = null; this.myPeerId = null; this.pollInterval = null; this.canSpeak = true; this.remoteAudios = {}; // userId -> Audio element this.isSelfMuted = false; this.isDeafened = false; this.audioContext = null; this.analyser = null; this.microphone = null; this.scriptProcessor = null; this.inputGain = null; this.isTalking = false; this.pttPressed = false; this.voxActive = false; this.lastVoiceTime = 0; this.voxHoldTime = 500; // Track who is speaking to persist across UI refreshes this.speakingUsers = new Set(); this.setupPTTListeners(); window.addEventListener('beforeunload', () => { // We don't want to leave on page refresh if we want persistence // but we might want to tell the server we are "still here" soon. // Actually, for a simple refresh, we just let the session timeout or re-join. }); // Auto-rejoin if we were in a channel setTimeout(() => { const savedChannelId = sessionStorage.getItem('activeVoiceChannel'); const savedPeerId = sessionStorage.getItem('activeVoicePeerId'); if (savedChannelId) { console.log('Auto-rejoining voice channel:', savedChannelId); if (savedPeerId) this.myPeerId = savedPeerId; this.join(savedChannelId, true); // Pass true to indicate auto-rejoin } }, 200); } setupPTTListeners() { window.addEventListener('keydown', (e) => { // Ignore if in input field if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; if (this.settings.mode !== 'ptt') return; const isMatch = e.key.toLowerCase() === this.settings.pttKey.toLowerCase() || (e.code && e.code.toLowerCase() === this.settings.pttKey.toLowerCase()) || (this.settings.pttKey === '0' && e.code === 'Numpad0'); if (isMatch) { if (!this.pttPressed) { console.log('PTT Key Pressed:', e.key, e.code, 'Expected:', this.settings.pttKey); this.pttPressed = true; this.updateMuteState(); } } }); window.addEventListener('keyup', (e) => { if (this.settings.mode !== 'ptt') return; const isMatch = e.key.toLowerCase() === this.settings.pttKey.toLowerCase() || (e.code && e.code.toLowerCase() === this.settings.pttKey.toLowerCase()) || (this.settings.pttKey === '0' && e.code === 'Numpad0'); if (isMatch) { console.log('PTT Key Released:', e.key, e.code, 'Expected:', this.settings.pttKey); this.pttPressed = false; this.updateMuteState(); } }); } async join(channelId, isAutoRejoin = false) { console.log('VoiceChannel.join process started for channel:', channelId, 'isAutoRejoin:', isAutoRejoin); if (this.currentChannelId === channelId && !isAutoRejoin) { console.log('Already in this channel'); return; } if (this.currentChannelId && this.currentChannelId != channelId) { console.log('Leaving previous channel:', this.currentChannelId); this.leave(); } this.currentChannelId = channelId; sessionStorage.setItem('activeVoiceChannel', channelId); try { console.log('Requesting microphone access with device:', this.settings.inputDevice); const constraints = { audio: { echoCancellation: this.settings.echoCancellation, noiseSuppression: this.settings.noiseSuppression, autoGainControl: true }, video: false }; if (this.settings.inputDevice !== 'default') { constraints.audio.deviceId = { exact: this.settings.inputDevice }; } this.localStream = await navigator.mediaDevices.getUserMedia(constraints); console.log('Microphone access granted'); this.setMute(false); // Join unmuted by default (self-mute off) // Always setup VOX logic for volume meter and detection this.setupVOX(); // Join via PHP console.log('Calling API join...'); const url = `api_v1_voice.php?action=join&room=${channelId}&name=${encodeURIComponent(window.currentUsername || 'Unknown')}${this.myPeerId ? '&peer_id='+this.myPeerId : ''}`; const resp = await fetch(url); const data = await resp.json(); console.log('API join response:', data); if (data.success) { this.myPeerId = data.peer_id; this.canSpeak = data.can_speak !== false; sessionStorage.setItem('activeVoicePeerId', this.myPeerId); console.log('Joined room with peer_id:', this.myPeerId); // Start polling this.startPolling(); this.updateVoiceUI(); } else { console.error('API join failed:', data.error); } } catch (e) { console.error('Failed to join voice:', e); alert('Microphone access required for voice channels. Error: ' + e.message); this.currentChannelId = null; } } startPolling() { if (this.pollInterval) clearInterval(this.pollInterval); this.pollInterval = setInterval(() => this.poll(), 1000); this.poll(); // Initial poll } async poll() { if (!this.myPeerId || !this.currentChannelId) return; try { const resp = await fetch(`api_v1_voice.php?action=poll&room=${this.currentChannelId}&peer_id=${this.myPeerId}&is_muted=${this.isSelfMuted ? 1 : 0}&is_deafened=${this.isDeafened ? 1 : 0}`); const data = await resp.json(); if (data.success) { this.canSpeak = data.can_speak !== false; // Update participants const oldPs = Object.keys(this.participants); this.participants = data.participants; const newPs = Object.keys(this.participants); // If new people joined, initiate offer if I'm the "older" one (not really necessary here, can just offer to anyone I don't have a peer for) newPs.forEach(pid => { if (pid !== this.myPeerId && !this.peers[pid]) { console.log('New peer found via poll:', pid); this.createPeerConnection(pid, true); } }); // Cleanup left peers oldPs.forEach(pid => { if (!this.participants[pid] && this.peers[pid]) { console.log('Peer left:', pid); this.peers[pid].close(); delete this.peers[pid]; } }); // Handle incoming signals if (data.signals && data.signals.length > 0) { for (const sig of data.signals) { await this.handleSignaling(sig); } } this.updateVoiceUI(); } } catch (e) { console.error('Polling error:', e); } } async sendSignal(to, data) { if (!this.myPeerId || !this.currentChannelId) return; await fetch(`api_v1_voice.php?action=signal&room=${this.currentChannelId}&peer_id=${this.myPeerId}&to=${to}&data=${encodeURIComponent(JSON.stringify(data))}`); } createPeerConnection(userId, isOfferor) { if (this.peers[userId]) return this.peers[userId]; console.log('Creating PeerConnection for:', userId, 'as offeror:', isOfferor); const pc = new RTCPeerConnection({ iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' } ] }); this.peers[userId] = pc; pc.oniceconnectionstatechange = () => { console.log(`ICE Connection State with ${userId}: ${pc.iceConnectionState}`); }; if (this.localStream) { this.localStream.getTracks().forEach(track => { console.log(`Adding track ${track.kind} to peer ${userId}`); pc.addTrack(track, this.localStream); }); } pc.onicecandidate = (event) => { if (event.candidate) { this.sendSignal(userId, { type: 'ice_candidate', candidate: event.candidate }); } }; pc.ontrack = (event) => { console.log('Received remote track from:', userId, 'Stream count:', event.streams.length); const stream = event.streams[0] || new MediaStream([event.track]); if (this.remoteAudios[userId]) { console.log('Replacing existing audio element for:', userId); this.remoteAudios[userId].pause(); this.remoteAudios[userId].srcObject = null; this.remoteAudios[userId].remove(); } const remoteAudio = new Audio(); remoteAudio.autoplay = true; remoteAudio.style.display = 'none'; remoteAudio.srcObject = stream; remoteAudio.muted = this.isDeafened; remoteAudio.volume = this.settings.outputVolume || 1.0; if (this.settings.outputDevice !== 'default' && typeof remoteAudio.setSinkId === 'function') { remoteAudio.setSinkId(this.settings.outputDevice); } document.body.appendChild(remoteAudio); this.remoteAudios[userId] = remoteAudio; console.log('Playing remote audio for:', userId); remoteAudio.play().then(() => { console.log('Remote audio playing successfully for:', userId); }).catch(e => { console.warn('Autoplay prevented or play failed for:', userId, e); // In case of autoplay prevention, we might need a user gesture, // but they just clicked a channel so it should be fine. }); }; if (isOfferor) { pc.createOffer().then(offer => { return pc.setLocalDescription(offer); }).then(() => { this.sendSignal(userId, { type: 'offer', offer: pc.localDescription }); }); } return pc; } async handleSignaling(sig) { const from = sig.from; const data = sig.data; console.log('Handling signaling from:', from, 'type:', data.type); switch (data.type) { case 'offer': await this.handleOffer(from, data.offer); break; case 'answer': await this.handleAnswer(from, data.answer); break; case 'ice_candidate': await this.handleCandidate(from, data.candidate); break; case 'voice_speaking': this.updateSpeakingUI(data.user_id, data.speaking); break; } } 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.sendSignal(from, { type: 'answer', answer: pc.localDescription }); } 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)); } setupVOX() { if (!this.localStream) { console.warn('Cannot setup VOX: no localStream'); return; } console.log('Setting up VOX logic...'); try { if (!this.audioContext) { this.audioContext = new (window.AudioContext || window.webkitAudioContext)(); } // Re-ensure context is running if (this.audioContext.state === 'suspended') { this.audioContext.resume().then(() => console.log('AudioContext resumed')); } // Cleanup old nodes if (this.scriptProcessor) { this.scriptProcessor.onaudioprocess = null; try { this.scriptProcessor.disconnect(); } catch(e) {} } if (this.microphone) { try { this.microphone.disconnect(); } catch(e) {} } this.analyser = this.audioContext.createAnalyser(); this.analyser.fftSize = 512; // Use a cloned stream for analysis so VOX works even when localStream is muted/disabled if (this.analysisStream) { this.analysisStream.getTracks().forEach(t => t.stop()); } this.analysisStream = this.localStream.clone(); this.analysisStream.getAudioTracks().forEach(t => t.enabled = true); // Ensure analysis stream is NOT muted this.microphone = this.audioContext.createMediaStreamSource(this.analysisStream); this.scriptProcessor = this.audioContext.createScriptProcessor(2048, 1, 1); this.microphone.connect(this.analyser); this.analyser.connect(this.scriptProcessor); // Avoid feedback: connect to a gain node with 0 volume then to destination const silence = this.audioContext.createGain(); silence.gain.value = 0; this.scriptProcessor.connect(silence); silence.connect(this.audioContext.destination); this.voxActive = false; this.currentVolume = 0; 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; this.currentVolume = average / 255; if (this.settings.mode !== 'vox') { this.voxActive = false; return; } if (this.currentVolume > 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(); } } }; console.log('VOX logic setup complete'); } catch (e) { console.error('Failed to setup VOX:', e); } } getVolume() { return this.currentVolume || 0; } updateMuteState() { if (!this.currentChannelId || !this.localStream) return; let shouldTalk = (this.settings.mode === 'ptt') ? this.pttPressed : this.voxActive; if (this.canSpeak === false) { shouldTalk = false; } console.log('updateMuteState: shouldTalk =', shouldTalk, 'mode =', this.settings.mode, 'canSpeak =', this.canSpeak); if (this.isTalking !== shouldTalk) { this.isTalking = shouldTalk; this.applyAudioState(); this.updateSpeakingUI(window.currentUserId, shouldTalk); // Notify others const msg = { type: 'voice_speaking', channel_id: this.currentChannelId, user_id: window.currentUserId, speaking: shouldTalk }; if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(msg)); } else { Object.keys(this.peers).forEach(pid => { this.sendSignal(pid, msg); }); } } } applyAudioState() { if (this.localStream) { const shouldTransmit = !this.isSelfMuted && this.isTalking && this.canSpeak; console.log('applyAudioState: transmitting =', shouldTransmit, '(selfMuted=', this.isSelfMuted, 'talking=', this.isTalking, 'canSpeak=', this.canSpeak, ')'); this.localStream.getAudioTracks().forEach(track => { track.enabled = shouldTransmit; }); } this.updateUserPanelButtons(); } setMute(mute) { this.isSelfMuted = mute; this.applyAudioState(); } toggleMute() { if (this.canSpeak === false) return; this.setMute(!this.isSelfMuted); } toggleDeafen() { this.isDeafened = !this.isDeafened; console.log('Setting deafen to:', this.isDeafened); Object.values(this.remoteAudios).forEach(audio => { audio.muted = this.isDeafened; if (!this.isDeafened) audio.volume = this.settings.outputVolume || 1.0; }); // If we deafen, we usually also mute in Discord if (this.isDeafened && !this.isSelfMuted) { this.setMute(true); } this.applyAudioState(); } setOutputVolume(vol) { this.settings.outputVolume = parseFloat(vol); Object.values(this.remoteAudios).forEach(audio => { audio.volume = this.settings.outputVolume; }); } setInputVolume(vol) { this.settings.inputVolume = parseFloat(vol); } async setInputDevice(deviceId) { this.settings.inputDevice = deviceId; if (this.currentChannelId && this.localStream) { const constraints = { audio: { echoCancellation: this.settings.echoCancellation, noiseSuppression: this.settings.noiseSuppression, autoGainControl: true }, video: false }; if (deviceId !== 'default') { constraints.audio.deviceId = { exact: deviceId }; } const newStream = await navigator.mediaDevices.getUserMedia(constraints); const newTrack = newStream.getAudioTracks()[0]; Object.values(this.peers).forEach(pc => { const sender = pc.getSenders().find(s => s.track && s.track.kind === 'audio'); if (sender) sender.replaceTrack(newTrack); }); this.localStream.getTracks().forEach(t => t.stop()); this.localStream = newStream; this.setupVOX(); this.applyAudioState(); } } async setOutputDevice(deviceId) { this.settings.outputDevice = deviceId; Object.values(this.remoteAudios).forEach(audio => { if (typeof audio.setSinkId === 'function') { audio.setSinkId(deviceId).catch(e => console.error('setSinkId failed:', e)); } }); } async updateAudioConstraints() { if (this.currentChannelId && this.localStream) { console.log('Updating audio constraints:', this.settings.echoCancellation, this.settings.noiseSuppression); const constraints = { audio: { echoCancellation: this.settings.echoCancellation, noiseSuppression: this.settings.noiseSuppression, autoGainControl: true }, video: false }; if (this.settings.inputDevice !== 'default') { constraints.audio.deviceId = { exact: this.settings.inputDevice }; } try { const newStream = await navigator.mediaDevices.getUserMedia(constraints); const newTrack = newStream.getAudioTracks()[0]; Object.values(this.peers).forEach(pc => { const sender = pc.getSenders().find(s => s.track && s.track.kind === 'audio'); if (sender) sender.replaceTrack(newTrack); }); this.localStream.getTracks().forEach(t => t.stop()); this.localStream = newStream; this.setupVOX(); this.applyAudioState(); } catch (e) { console.error('Failed to update audio constraints:', e); } } } updateUserPanelButtons() { const btnMute = document.getElementById('btn-panel-mute'); const btnDeafen = document.getElementById('btn-panel-deafen'); let displayMuted = this.isSelfMuted; if (this.canSpeak === false) { displayMuted = true; } if (btnMute) { btnMute.classList.toggle('active', displayMuted); btnMute.style.color = displayMuted ? '#f23f43' : 'var(--text-muted)'; btnMute.innerHTML = displayMuted ? '' : ''; if (this.canSpeak === false) { btnMute.title = "You do not have permission to speak in this channel"; btnMute.style.opacity = '0.5'; } else { btnMute.title = "Mute"; btnMute.style.opacity = '1'; } } if (btnDeafen) { btnDeafen.classList.toggle('active', this.isDeafened); btnDeafen.style.color = this.isDeafened ? '#f23f43' : 'var(--text-muted)'; btnDeafen.innerHTML = this.isDeafened ? '' : ''; } } leave() { if (!this.currentChannelId) { console.log('VoiceChannel.leave called but no active channel'); return; } console.log('Leaving voice channel:', this.currentChannelId, 'myPeerId:', this.myPeerId); const cid = this.currentChannelId; const pid = this.myPeerId; sessionStorage.removeItem('activeVoiceChannel'); sessionStorage.removeItem('activeVoicePeerId'); if (this.pollInterval) clearInterval(this.pollInterval); // Use keepalive for the leave fetch to ensure it reaches the server during page unload fetch(`api_v1_voice.php?action=leave&room=${cid}&peer_id=${pid}`, { keepalive: true }); if (this.localStream) { console.log('Stopping local stream tracks'); this.localStream.getTracks().forEach(track => { track.stop(); console.log('Track stopped:', track.kind); }); this.localStream = null; } if (this.analysisStream) { this.analysisStream.getTracks().forEach(track => track.stop()); this.analysisStream = null; } if (this.scriptProcessor) { try { this.scriptProcessor.disconnect(); this.scriptProcessor.onaudioprocess = null; } catch(e) {} this.scriptProcessor = null; } if (this.microphone) { try { this.microphone.disconnect(); } catch(e) {} this.microphone = null; } if (this.audioContext && this.audioContext.state !== 'closed') { // Keep AudioContext alive but suspended to reuse it this.audioContext.suspend(); } Object.values(this.peers).forEach(pc => pc.close()); Object.values(this.remoteAudios).forEach(audio => { audio.pause(); audio.remove(); audio.srcObject = null; }); this.peers = {}; this.remoteAudios = {}; this.participants = {}; this.currentChannelId = null; this.myPeerId = null; this.speakingUsers.clear(); // Also remove 'active' class from all voice items document.querySelectorAll('.voice-item').forEach(el => el.classList.remove('active')); this.updateVoiceUI(); } updateVoiceUI() { // We now use a global update mechanism for all channels VoiceChannel.refreshAllVoiceUsers(); if (this.currentChannelId) { 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 = `