diff --git a/assets/js/main.js b/assets/js/main.js index ddb7f8f..a8c7104 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -480,7 +480,7 @@ document.addEventListener('DOMContentLoaded', () => { let voiceHandler; if (typeof VoiceChannel !== 'undefined') { - voiceHandler = new VoiceChannel(null, window.voiceParamètres); + voiceHandler = new VoiceChannel(null, window.voiceSettings); window.voiceHandler = voiceHandler; console.log('VoiceHandler initialized'); diff --git a/assets/js/voice.js b/assets/js/voice.js index ad5d644..044472d 100644 --- a/assets/js/voice.js +++ b/assets/js/voice.js @@ -45,18 +45,13 @@ class VoiceChannel { this.pttPressed = false; this.voxActive = false; this.lastVoiceTime = 0; - this.voxHoldTime = 500; + this.voxHoldTime = 400; // Slightly shorter hold time for better responsiveness // Track who is speaking to persist across UI refreshes this.speakingUsers = new Set(); this.setupPTTListeners(); this.loadWhisperSettings(); - 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(() => { @@ -70,6 +65,11 @@ class VoiceChannel { }, 200); } + // Alias for index.php compatibility + set whisperParamètres(val) { + this.whisperSettings = val; + } + getAudioConstraints() { const useAdvanced = this.settings.advancedFilters !== false; @@ -86,15 +86,14 @@ class VoiceChannel { googAudioMirroring: { ideal: false }, googNoiseReduction: { ideal: this.settings.noiseSuppression }, googAutoGainControl2: { ideal: useAdvanced }, - googAudioMirroring: { ideal: false }, // Standard constraints channelCount: { ideal: 1 }, sampleRate: { ideal: 48000 }, sampleSize: { ideal: 16 } }; - if (this.settings.inputDevice !== 'default') { - constraints.deviceId = { exact: this.settings.inputDevice }; + if (this.settings.inputDevice && this.settings.inputDevice !== 'default') { + constraints.deviceId = { ideal: this.settings.inputDevice }; } return constraints; @@ -206,9 +205,6 @@ class VoiceChannel { this.isWhispering = false; this.whisperPeers.clear(); this.updateMuteState(); - - // Optionally cleanup peers that are NOT in current channel - // For now, keep them for future whispers to avoid re-handshake } async join(channelId, isAutoRejoin = false) { @@ -231,7 +227,14 @@ class VoiceChannel { audio: this.getAudioConstraints(), video: false }; - this.localStream = await navigator.mediaDevices.getUserMedia(constraints); + + try { + this.localStream = await navigator.mediaDevices.getUserMedia(constraints); + } catch (err) { + console.warn('Advanced constraints failed, falling back to basic audio:', err); + this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); + } + console.log('Microphone access granted'); this.setMute(false); // Join unmuted by default (self-mute off) @@ -284,7 +287,7 @@ class VoiceChannel { 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) + // If new people joined, initiate offer newPs.forEach(pid => { if (pid !== this.myPeerId && !this.peers[pid]) { console.log('New peer found via poll:', pid); @@ -337,7 +340,8 @@ class VoiceChannel { const pc = new RTCPeerConnection({ iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, - { urls: 'stun:stun1.l.google.com:19302' } + { urls: 'stun:stun1.l.google.com:19302' }, + { urls: 'stun:stun2.l.google.com:19302' } ] }); @@ -345,10 +349,6 @@ class VoiceChannel { pc.oniceconnectionstatechange = () => { console.log(`ICE Connection State with ${userId}: ${pc.iceConnectionState}`); - if (pc.iceConnectionState === 'failed' || pc.iceConnectionState === 'disconnected') { - console.log(`ICE failure with ${userId}, attempting to restart...`); - // If it failed, we could try to renegotiate, but for now let's just wait for poll to maybe clean it up - } }; pc.onnegotiationneeded = async () => { @@ -365,7 +365,6 @@ class VoiceChannel { if (this.localStream) { this.localStream.getTracks().forEach(track => { - console.log(`Adding track ${track.kind} to peer ${userId}`); pc.addTrack(track, this.localStream); }); } @@ -377,16 +376,14 @@ class VoiceChannel { }; pc.ontrack = (event) => { - console.log('Received remote track from:', userId, 'Stream count:', event.streams.length); + console.log('Received remote track from:', userId); const stream = event.streams[0] || new MediaStream([event.track]); - // Ensure AudioContext is running if (this.audioContext && this.audioContext.state === 'suspended') { this.audioContext.resume(); } 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(); @@ -404,16 +401,11 @@ class VoiceChannel { 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 + remoteAudio.play().catch(e => { + console.warn('Autoplay prevented for:', userId, e); }); }; - // Manual offer if explicitly requested (though onnegotiationneeded should handle it) if (isOfferor && pc.signalingState === 'stable') { pc.onnegotiationneeded(); } @@ -425,8 +417,6 @@ class VoiceChannel { const from = sig.from; const data = sig.data; - console.log('Handling signaling from:', from, 'type:', data.type); - try { switch (data.type) { case 'offer': @@ -450,18 +440,11 @@ class VoiceChannel { async handleOffer(from, offer) { const pc = this.createPeerConnection(from, false); const state = this.peerStates[from]; - - const offerCollision = (offer.type === "offer") && - (state.makingOffer || pc.signalingState !== "stable"); - - // Politeness: higher peer_id is polite + const offerCollision = (offer.type === "offer") && (state.makingOffer || pc.signalingState !== "stable"); const isPolite = this.myPeerId > from; state.ignoreOffer = !isPolite && offerCollision; - if (state.ignoreOffer) { - console.log('Polite peer: ignoring offer from impolite peer to avoid collision', from); - return; - } + if (state.ignoreOffer) return; await pc.setRemoteDescription(new RTCSessionDescription(offer)); if (offer.type === "offer") { @@ -479,36 +462,25 @@ class VoiceChannel { async handleCandidate(from, candidate) { const pc = this.peers[from]; - const state = this.peerStates[from]; try { if (pc) { await pc.addIceCandidate(new RTCIceCandidate(candidate)); } - } catch (err) { - if (!state || !state.ignoreOffer) { - console.warn('Failed to add ICE candidate', err); - } - } + } catch (err) {} } setupVOX() { - if (!this.localStream) { - console.warn('Cannot setup VOX: no localStream'); - return; - } + if (!this.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')); + this.audioContext.resume(); } - // Cleanup old nodes if (this.scriptProcessor) { this.scriptProcessor.onaudioprocess = null; try { this.scriptProcessor.disconnect(); } catch(e) {} @@ -518,14 +490,14 @@ class VoiceChannel { } this.analyser = this.audioContext.createAnalyser(); - this.analyser.fftSize = 512; + this.analyser.fftSize = 1024; // Better resolution + this.analyser.smoothingTimeConstant = 0.3; // Less jitter - // 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.analysisStream.getAudioTracks().forEach(t => t.enabled = true); this.microphone = this.audioContext.createMediaStreamSource(this.analysisStream); this.scriptProcessor = this.audioContext.createScriptProcessor(2048, 1, 1); @@ -533,7 +505,6 @@ class VoiceChannel { 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); @@ -542,13 +513,21 @@ class VoiceChannel { this.voxActive = false; this.currentVolume = 0; + const buffer = new Float32Array(this.analyser.fftSize); + 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; + // Use Time Domain Data (Waveform) for better volume measurement (RMS) + this.analyser.getFloatTimeDomainData(buffer); + + let sum = 0; + for (let i = 0; i < buffer.length; i++) { + sum += buffer[i] * buffer[i]; + } + const rms = Math.sqrt(sum / buffer.length); + + // Scale RMS to 0-1. Speech peak is usually around 0.1-0.3 RMS. + // We'll normalize it so the slider at 0.1 feels natural. + this.currentVolume = Math.min(1.0, rms * 3); if (this.settings.mode !== 'vox') { this.voxActive = false; @@ -568,7 +547,6 @@ class VoiceChannel { } } }; - console.log('VOX logic setup complete'); } catch (e) { console.error('Failed to setup VOX:', e); } @@ -581,21 +559,10 @@ class VoiceChannel { updateMuteState() { if (!this.localStream) return; - // If we are not in a channel, we can still whisper! - // But for normal talking, we need currentChannelId. - let shouldTalk = (this.settings.mode === 'ptt') ? this.pttPressed : this.voxActive; - - if (this.canSpeak === false) { - shouldTalk = false; - } + if (this.canSpeak === false) shouldTalk = false; + if (this.isWhispering) shouldTalk = true; - // Always allow talking if whispering - if (this.isWhispering) { - shouldTalk = true; - } - - console.log('updateMuteState: shouldTalk =', shouldTalk, 'isWhispering =', this.isWhispering); if (this.isTalking !== shouldTalk || this.lastWhisperState !== this.isWhispering) { this.isTalking = shouldTalk; this.lastWhisperState = this.isWhispering; @@ -603,7 +570,6 @@ class VoiceChannel { this.applyAudioState(); this.updateSpeakingUI(window.currentUserId, shouldTalk, this.isWhispering); - // Notify others in current channel const msg = { type: 'voice_speaking', channel_id: this.currentChannelId, @@ -612,15 +578,11 @@ class VoiceChannel { is_whisper: this.isWhispering }; - // Send to channel peers Object.keys(this.peers).forEach(pid => { - // If we are whispering, only send voice_speaking to whisper targets - // but actually it's better to notify channel peers that we are NOT talking to them if (this.isWhispering) { if (this.whisperPeers.has(pid)) { this.sendSignal(pid, msg); } else { - // Tell channel peers we are silent to them this.sendSignal(pid, { ...msg, speaking: false }); } } else { @@ -628,14 +590,9 @@ class VoiceChannel { } }); - // Also notify whisper peers that are NOT in the channel if (this.isWhispering) { this.whisperPeers.forEach(pid => { - if (!this.peers[pid]) { - // This should have been established in startWhisper - } else { - this.sendSignal(pid, msg); - } + if (this.peers[pid]) this.sendSignal(pid, msg); }); } } @@ -644,24 +601,16 @@ class VoiceChannel { applyAudioState() { if (this.localStream) { const shouldTransmit = !this.isSelfMuted && this.isTalking && (this.canSpeak || this.isWhispering); - console.log('applyAudioState: transmitting =', shouldTransmit, '(whisper=', this.isWhispering, ')'); - this.localStream.getAudioTracks().forEach(track => { track.enabled = shouldTransmit; }); - // We also need to ensure the audio only goes to the right peers - // In P2P, we do this by enabling/disabling the track in the peer connection - // or by simply enabling/disabling the local track (which affects all peers). - // To be truly private, we should only enable the track for whisper peers. - Object.entries(this.peers).forEach(([pid, pc]) => { const sender = pc.getSenders().find(s => s.track && s.track.kind === 'audio'); if (sender) { if (this.isWhispering) { sender.track.enabled = this.whisperPeers.has(pid); } else { - // Normal mode: only send to people in the current channel participants sender.track.enabled = !!this.participants[pid]; } } @@ -682,12 +631,10 @@ class VoiceChannel { 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 Corvara if (this.isDeafened && !this.isSelfMuted) { this.setMute(true); } @@ -708,10 +655,7 @@ class VoiceChannel { async setInputDevice(deviceId) { this.settings.inputDevice = deviceId; if (this.currentChannelId && this.localStream) { - const constraints = { - audio: this.getAudioConstraints(), - video: false - }; + const constraints = { audio: this.getAudioConstraints(), video: false }; const newStream = await navigator.mediaDevices.getUserMedia(constraints); const newTrack = newStream.getAudioTracks()[0]; @@ -738,11 +682,7 @@ class VoiceChannel { async updateAudioConstraints() { if (this.currentChannelId && this.localStream) { - console.log('Updating audio constraints:', this.settings.echoCancellation, this.settings.noiseSuppression, this.settings.advancedFilters); - const constraints = { - audio: this.getAudioConstraints(), - video: false - }; + const constraints = { audio: this.getAudioConstraints(), video: false }; try { const newStream = await navigator.mediaDevices.getUserMedia(constraints); const newTrack = newStream.getAudioTracks()[0]; @@ -767,24 +707,14 @@ class VoiceChannel { const btnDeafen = document.getElementById('btn-panel-deafen'); let displayMuted = this.isSelfMuted; - if (this.canSpeak === false) { - displayMuted = true; - } + 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) { @@ -797,11 +727,7 @@ class VoiceChannel { } leave() { - if (!this.currentChannelId) { - console.log('VoiceChannel.leave called but no active channel'); - return; - } - console.log('Leaving voice channel:', this.currentChannelId, 'myPeerId:', this.myPeerId); + if (!this.currentChannelId) return; const cid = this.currentChannelId; const pid = this.myPeerId; @@ -809,15 +735,10 @@ class VoiceChannel { 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.getTracks().forEach(track => track.stop()); this.localStream = null; } if (this.analysisStream) { @@ -836,10 +757,6 @@ class VoiceChannel { 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 => { @@ -854,16 +771,12 @@ class VoiceChannel { 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'); @@ -909,8 +822,6 @@ class VoiceChannel { avatar.style.boxShadow = 'none'; } } - - // Show whisper indicator text if whispering to me if (isWhisper && isSpeaking && userId !== String(window.currentUserId)) { if (!el.querySelector('.whisper-label')) { const label = document.createElement('span'); @@ -931,31 +842,21 @@ class VoiceChannel { const resp = await fetch('api_v1_voice.php?action=list_all'); const data = await resp.json(); if (data.success) { - // Clear all lists first document.querySelectorAll('.voice-users-list').forEach(el => el.innerHTML = ''); - - // Remove connected highlight from all voice items - document.querySelectorAll('.voice-item').forEach(el => { - el.classList.remove('connected'); - }); + document.querySelectorAll('.voice-item').forEach(el => el.classList.remove('connected')); - // Populate based on data - const processedUserIds = new Set(); Object.keys(data.channels).forEach(channelId => { const voiceItem = document.querySelector(`.voice-item[data-channel-id="${channelId}"]`); if (voiceItem) { - // Highlight channel as connected only if I am in it if (window.voiceHandler && window.voiceHandler.currentChannelId == channelId) { voiceItem.classList.add('connected'); } - const container = voiceItem.closest('.channel-item-container'); if (container) { const listEl = container.querySelector('.voice-users-list'); if (listEl) { data.channels[channelId].forEach(p => { const pid = String(p.user_id); - processedUserIds.add(pid); const isSpeaking = window.voiceHandler && window.voiceHandler.speakingUsers.has(pid); VoiceChannel.renderUserToUI(listEl, p.user_id, p.display_name || p.username, p.avatar_url, isSpeaking, p.is_muted, p.is_deafened); }); @@ -963,19 +864,6 @@ class VoiceChannel { } } }); - - // Handle users whispering to me from other channels or not in any channel - if (window.voiceHandler && window.voiceHandler.speakingUsers.size > 0) { - window.voiceHandler.speakingUsers.forEach(uid => { - if (!processedUserIds.has(uid)) { - // Find where to show this user. For now, let's put them in their own channel if possible, - // or just a "Whispers" section if we had one. - // Actually, let's just show them in whatever channel they are currently in. - // The `data.channels` already contains everyone. - // If they are not in `processedUserIds` it means their channel is not rendered or they are not in a channel. - } - }); - } } } catch (e) { console.error('Failed to refresh voice users:', e); @@ -991,11 +879,8 @@ class VoiceChannel { const boxShadow = isSpeaking ? 'box-shadow: 0 0 0 2px #23a559;' : ''; let icons = ''; - if (isDeafened) { - icons += ''; - } else if (isMuted) { - icons += ''; - } + if (isDeafened) icons += ''; + else if (isMuted) icons += ''; userEl.innerHTML = `
@@ -1004,4 +889,4 @@ class VoiceChannel { `; container.appendChild(userEl); } -} +} \ No newline at end of file diff --git a/index.php b/index.php index 63d21a5..a40ec2e 100644 --- a/index.php +++ b/index.php @@ -476,7 +476,7 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; window.currentChannelName = ""; window.isDndMode = ; window.soundNotifications = ; - window.voiceParamètres = { + window.voiceSettings = { mode: "", pttKey: "", voxThreshold: , @@ -2145,7 +2145,7 @@ document.addEventListener('DOMContentLoaded', () => { // But actually 1.0 means high threshold, so quiet needs more voice // 0.1 means low threshold, easy to trigger. // Meter is 0 to 100%. - meterThreshold.style.left = (voxThresholdInput.value * 100) + '%'; + meterThreshold.style.left = (voxThresholdInput.value * 100) + '%'; if (window.voiceHandler) window.voiceHandler.settings.voxThreshold = parseFloat(voxThresholdInput.value); }; voxThresholdInput.addEventListener('input', updateThresholdPos); updateThresholdPos();