From 2ba9d8d8ffb117a4a87020b040367ccf34744e3f Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Mon, 30 Mar 2026 09:50:00 +0000 Subject: [PATCH] Release V1.7.5 --- assets/js/rnnoise-processor.js | 79 +++++++++-------- assets/js/voice.js | 151 +++++++++++++++++++-------------- 2 files changed, 131 insertions(+), 99 deletions(-) diff --git a/assets/js/rnnoise-processor.js b/assets/js/rnnoise-processor.js index 15c8ec0..30eb980 100644 --- a/assets/js/rnnoise-processor.js +++ b/assets/js/rnnoise-processor.js @@ -8,14 +8,26 @@ class RNNoiseProcessor extends AudioWorkletProcessor { this.wasmInstance = null; this.rnnoise = null; this.initialized = false; - this.inputBuffer = new Float32Array(480); // RNNoise expects 480 samples (10ms at 48kHz) - this.outputBuffer = new Float32Array(480); + + // RNNoise expects 480 samples (10ms at 48kHz) + this.FRAME_SIZE = 480; + + // Buffers + this.inputBuffer = new Float32Array(this.FRAME_SIZE); + this.outputBuffer = new Float32Array(this.FRAME_SIZE); + + // Circular buffer for output to handle latency and fixed frame size + this.outCircleBuffer = new Float32Array(this.FRAME_SIZE * 2); + this.outReadPtr = 0; + this.outWritePtr = 0; + this.outCount = 0; + this.bufferPtr = 0; this.heapInputPtr = null; this.heapOutputPtr = null; this.statePtr = null; - this.vadThreshold = 0.85; // Default strict threshold + this.vadThreshold = 0.85; this.enabled = true; this.port.onmessage = (event) => { @@ -40,16 +52,12 @@ class RNNoiseProcessor extends AudioWorkletProcessor { this.wasmInstance = wasmModule.instance; this.rnnoise = this.wasmInstance.exports; - // Allocate memory on the WASM heap - // rnnoise_create returns a pointer to the state this.statePtr = this.rnnoise.rnnoise_create(0); - - // Buffer size is 480 floats (4 bytes each) - this.heapInputPtr = this.rnnoise.malloc(480 * 4); - this.heapOutputPtr = this.rnnoise.malloc(480 * 4); + this.heapInputPtr = this.rnnoise.malloc(this.FRAME_SIZE * 4); + this.heapOutputPtr = this.rnnoise.malloc(this.FRAME_SIZE * 4); this.initialized = true; - console.log("RNNoise Processor Initialized with WASM"); + console.log("RNNoise Processor Initialized"); } catch (e) { console.error("Failed to initialize RNNoise WASM:", e); } @@ -59,55 +67,56 @@ class RNNoiseProcessor extends AudioWorkletProcessor { const input = inputs[0]; const output = outputs[0]; - if (!input || !input[0] || !this.initialized || !this.enabled) { - // Pas d'entrée ou pas initialisé, on bypass - if (input && input[0] && output && output[0]) { - output[0].set(input[0]); - } + if (!input || !input[0] || !output || !output[0]) return true; + + if (!this.initialized || !this.enabled) { + output[0].set(input[0]); return true; } const inputChannel = input[0]; const outputChannel = output[0]; - // Fill our internal buffer until we have 480 samples for (let i = 0; i < inputChannel.length; i++) { - this.inputBuffer[this.bufferPtr] = inputChannel[i] * 32768.0; // RNNoise expects 16-bit PCM range + // Store input + this.inputBuffer[this.bufferPtr] = inputChannel[i] * 32768.0; this.bufferPtr++; - if (this.bufferPtr >= 480) { - // Process 480 samples + if (this.bufferPtr >= this.FRAME_SIZE) { this.processRNNoise(); this.bufferPtr = 0; } - // Output from our buffer (with latency of 480 samples) - // We use a simple circular buffer approach here for the output too - outputChannel[i] = (this.outputBuffer[this.bufferPtr] / 32768.0); + // Read from circular output buffer if available, else output silence/last + if (this.outCount > 0) { + outputChannel[i] = this.outCircleBuffer[this.outReadPtr]; + this.outReadPtr = (this.outReadPtr + 1) % this.outCircleBuffer.length; + this.outCount--; + } else { + outputChannel[i] = 0; + } } return true; } processRNNoise() { - // Copy input to WASM heap - const heapInput = new Float32Array(this.rnnoise.memory.buffer, this.heapInputPtr, 480); + const heapInput = new Float32Array(this.rnnoise.memory.buffer, this.heapInputPtr, this.FRAME_SIZE); heapInput.set(this.inputBuffer); - // Process audio: rnnoise_process_frame(state, output, input) - // Returns the probability of speech (0.0 to 1.0) const vadProbability = this.rnnoise.rnnoise_process_frame(this.statePtr, this.heapOutputPtr, this.heapInputPtr); - // Copy output from WASM heap - const heapOutput = new Float32Array(this.rnnoise.memory.buffer, this.heapOutputPtr, 480); + const heapOutput = new Float32Array(this.rnnoise.memory.buffer, this.heapOutputPtr, this.FRAME_SIZE); - // Aggressive Voice Gate based on RNNoise VAD - if (vadProbability < this.vadThreshold) { - // Not voice -> Mute completely - this.outputBuffer.fill(0); - } else { - // Voice detected -> Copy denoised output - this.outputBuffer.set(heapOutput); + // Write to circular output buffer + for (let i = 0; i < this.FRAME_SIZE; i++) { + let sample = heapOutput[i] / 32768.0; + // Apply gate + if (vadProbability < this.vadThreshold) sample = 0; + + this.outCircleBuffer[this.outWritePtr] = sample; + this.outWritePtr = (this.outWritePtr + 1) % this.outCircleBuffer.length; + this.outCount++; } } } diff --git a/assets/js/voice.js b/assets/js/voice.js index 196db8b..baee772 100644 --- a/assets/js/voice.js +++ b/assets/js/voice.js @@ -19,17 +19,18 @@ class VoiceChannel { this.localStream = null; this.processedStream = null; this.analysisStream = null; - this.peers = {}; // userId -> RTCPeerConnection - this.participants = {}; // userId -> {name} + this.peers = {}; // peer_id -> RTCPeerConnection + this.participants = {}; // peer_id -> {user_id, name, ...} + this.peerToUserMap = {}; // peer_id -> user_id (as string) this.currentChannelId = null; this.myPeerId = null; this.pollInterval = null; this.canSpeak = true; - this.remoteAudios = {}; // userId -> Audio element + this.remoteAudios = {}; // user_id -> Audio element this.isSelfMuted = false; this.isDeafened = false; - this.peerStates = {}; // userId -> { makingOffer, ignoreOffer } + this.peerStates = {}; // peer_id -> { makingOffer, ignoreOffer } this.whisperSettings = []; // from DB this.whisperPeers = new Set(); // active whisper target peer_ids @@ -51,8 +52,8 @@ class VoiceChannel { this.lastVoiceTime = 0; this.voxHoldTime = 400; - this.speakingUsers = new Set(); - this.remoteGainNodes = {}; // userId -> GainNode + this.speakingUsers = new Set(); // user_id (as string) + this.remoteGainNodes = {}; // user_id -> GainNode this.userGains = JSON.parse(localStorage.getItem("voice_user_gains") || "{}"); this.setupPTTListeners(); @@ -81,15 +82,15 @@ class VoiceChannel { return { echoCancellation: ec, noiseSuppression: ns, - autoGainControl: false, + autoGainControl: true, googEchoCancellation: ec, - googAutoGainControl: false, + googAutoGainControl: true, googNoiseSuppression: ns, googHighpassFilter: useAdvanced, googTypingNoiseDetection: true, googAudioMirroring: false, googNoiseReduction: ns, - googAutoGainControl2: false, + googAutoGainControl2: true, channelCount: 1, sampleRate: 48000, sampleSize: 16, @@ -210,6 +211,15 @@ class VoiceChannel { this.myPeerId = data.peer_id; this.canSpeak = data.can_speak !== false; sessionStorage.setItem('activeVoicePeerId', this.myPeerId); + + // Pre-populate participants list immediately + if (data.participants) { + this.participants = data.participants; + Object.entries(this.participants).forEach(([pid, p]) => { + this.peerToUserMap[pid] = String(p.user_id); + }); + } + this.startPolling(); this.updateVoiceUI(); } @@ -236,18 +246,26 @@ class VoiceChannel { const oldPs = Object.keys(this.participants); this.participants = data.participants; const newPs = Object.keys(this.participants); + + // Update Peer to User mapping + Object.entries(this.participants).forEach(([pid, p]) => { + this.peerToUserMap[pid] = String(p.user_id); + }); + newPs.forEach(pid => { if (pid !== this.myPeerId && !this.peers[pid]) this.createPeerConnection(pid, true); }); oldPs.forEach(pid => { - if (!this.participants[pid] && this.peers[pid] && !this.whisperPeers.has(pid) && !this.speakingUsers.has(pid)) { + if (!this.participants[pid] && this.peers[pid] && !this.whisperPeers.has(pid)) { + const uid = this.peerToUserMap[pid]; this.peers[pid].close(); delete this.peers[pid]; - if (this.remoteAudios[pid]) { - this.remoteAudios[pid].pause(); - this.remoteAudios[pid].srcObject = null; - this.remoteAudios[pid].remove(); - delete this.remoteAudios[pid]; + if (uid && this.remoteAudios[uid]) { + this.remoteAudios[uid].pause(); + this.remoteAudios[uid].srcObject = null; + this.remoteAudios[uid].remove(); + delete this.remoteAudios[uid]; + delete this.remoteGainNodes[uid]; } } }); @@ -264,9 +282,9 @@ class VoiceChannel { 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]; - if (!this.peerStates[userId]) this.peerStates[userId] = { makingOffer: false, ignoreOffer: false }; + createPeerConnection(peerId, isOfferor) { + if (this.peers[peerId]) return this.peers[peerId]; + if (!this.peerStates[peerId]) this.peerStates[peerId] = { makingOffer: false, ignoreOffer: false }; const pc = new RTCPeerConnection({ iceServers: [ @@ -275,15 +293,15 @@ class VoiceChannel { { urls: 'stun:stun2.l.google.com:19302' } ] }); - this.peers[userId] = pc; + this.peers[peerId] = pc; pc.onnegotiationneeded = async () => { try { - this.peerStates[userId].makingOffer = true; + this.peerStates[peerId].makingOffer = true; await pc.setLocalDescription(); - this.sendSignal(userId, { type: 'offer', offer: pc.localDescription }); + this.sendSignal(peerId, { type: 'offer', offer: pc.localDescription }); } catch (err) { console.error('onnegotiationneeded error:', err); } - finally { this.peerStates[userId].makingOffer = false; } + finally { this.peerStates[peerId].makingOffer = false; } }; const streamToUse = this.processedStream || this.localStream; @@ -292,11 +310,17 @@ class VoiceChannel { } pc.onicecandidate = (event) => { - if (event.candidate) this.sendSignal(userId, { type: 'ice_candidate', candidate: event.candidate }); + if (event.candidate) this.sendSignal(peerId, { type: 'ice_candidate', candidate: event.candidate }); }; pc.ontrack = (event) => { const stream = event.streams[0] || new MediaStream([event.track]); + const userId = this.peerToUserMap[peerId]; + if (!userId) { + console.warn('Unknown userId for peerId:', peerId, '- Will retry when participant info arrives'); + return; + } + if (!this.audioContext) { this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 48000, @@ -315,9 +339,8 @@ class VoiceChannel { const gainNode = this.audioContext.createGain(); const destination = this.audioContext.createMediaStreamDestination(); - // Initialiser le gain de l utilisateur - const userGain = this.userGains[userId] !== undefined ? parseFloat(this.userGains[userId]) : 1.0; - gainNode.gain.value = userGain; + const uiGain = this.userGains[userId] !== undefined ? parseFloat(this.userGains[userId]) : 0; + gainNode.gain.value = this.calculateActualGain(uiGain); source.connect(gainNode); gainNode.connect(destination); @@ -342,7 +365,7 @@ class VoiceChannel { } async handleSignaling(sig) { - const from = sig.from; + const from = sig.from; const data = sig.data; try { switch (data.type) { @@ -401,15 +424,13 @@ class VoiceChannel { } catch (e) { console.error('Failed to load RNNoise:', e); } } - // Cleanup old nodes - [this.scriptProcessor, this.rnnoiseNode, this.inputGainNode, this.microphone].forEach(node => { + [this.scriptProcessor, this.rnnoiseNode, this.inputGainNode, this.microphone, this.analyser].forEach(node => { if (node) { try { node.disconnect(); } catch(e) {} } }); this.microphone = this.audioContext.createMediaStreamSource(this.localStream); let lastNode = this.microphone; - // 1. Noise Suppression (RNNoise) FIRST - so it processes non-amplified signal if (this.rnnoiseLoaded && this.settings.noiseSuppression) { console.log('Activating RNNoise suppression node (PRE-GAIN)'); this.rnnoiseNode = new AudioWorkletNode(this.audioContext, 'rnnoise-processor'); @@ -419,27 +440,22 @@ class VoiceChannel { lastNode = this.rnnoiseNode; } - // 2. Input Gain (Amplification) AFTER Noise Suppression this.inputGainNode = this.audioContext.createGain(); this.inputGainNode.gain.value = this.isSelfMuted ? 0 : (this.settings.inputVolume || 1.0); lastNode.connect(this.inputGainNode); lastNode = this.inputGainNode; - // Destination for processed stream const destination = this.audioContext.createMediaStreamDestination(); lastNode.connect(destination); this.processedStream = destination.stream; - // Update peer tracks Object.values(this.peers).forEach(pc => { const sender = pc.getSenders().find(s => s.track && s.track.kind === 'audio'); if (sender) { - console.log('Replacing track for peer to use processed (denoised) stream'); sender.replaceTrack(this.processedStream.getAudioTracks()[0]); } }); - // Analysis for VOX (on the FINAL processed signal) this.analyser = this.audioContext.createAnalyser(); this.analyser.fftSize = 1024; this.analyser.smoothingTimeConstant = 0.3; @@ -471,6 +487,9 @@ class VoiceChannel { } } }; + + this.applyAudioState(); + } catch (e) { console.error('Failed setupVOX:', e); } } @@ -482,7 +501,6 @@ class VoiceChannel { if (this.canSpeak === false) shouldTalk = false; if (this.isWhispering) shouldTalk = true; - // Transmission is only possible if NOT self-muted const shouldTransmit = !this.isSelfMuted && shouldTalk; if (this.isTalking !== shouldTransmit || this.lastWhisperState !== this.isWhispering) { @@ -490,7 +508,6 @@ class VoiceChannel { this.lastWhisperState = this.isWhispering; this.applyAudioState(); - // Only update UI and send signals if we are actually transmitting this.updateSpeakingUI(window.currentUserId, shouldTransmit, this.isWhispering); const msg = { type: 'voice_speaking', channel_id: this.currentChannelId, user_id: window.currentUserId, speaking: shouldTransmit, is_whisper: this.isWhispering }; Object.keys(this.peers).forEach(pid => { @@ -506,7 +523,6 @@ class VoiceChannel { const streamToUse = this.processedStream || this.localStream; const shouldTransmit = !this.isSelfMuted && this.isTalking && (this.canSpeak || this.isWhispering); - // Safety: ensure gain is 0 if muted to prevent any signal from reaching the analyzer or destination if (this.inputGainNode && this.audioContext) { const targetGain = this.isSelfMuted ? 0 : (this.settings.inputVolume || 1.0); this.inputGainNode.gain.setTargetAtTime(targetGain, this.audioContext.currentTime, 0.01); @@ -516,11 +532,10 @@ class VoiceChannel { streamToUse.getAudioTracks().forEach(track => { track.enabled = shouldTransmit; }); Object.entries(this.peers).forEach(([pid, pc]) => { const sender = pc.getSenders().find(s => s.track && s.track.kind === 'audio'); - if (sender) { + if (sender && sender.track) { if (this.isWhispering) { sender.track.enabled = shouldTransmit && this.whisperPeers.has(pid); } else { - // FIX: Ensure track is only enabled if shouldTransmit is true sender.track.enabled = shouldTransmit && !!this.participants[pid]; } } @@ -532,7 +547,7 @@ class VoiceChannel { setMute(mute) { this.isSelfMuted = mute; this.updateMuteState(); - this.applyAudioState(); // Always update UI even if not "talking" + this.applyAudioState(); } toggleMute() { if (this.canSpeak !== false) this.setMute(!this.isSelfMuted); } toggleDeafen() { @@ -552,12 +567,21 @@ class VoiceChannel { Object.values(this.remoteAudios).forEach(audio => { audio.volume = this.settings.outputVolume; }); } - setUserGain(userId, volume) { + calculateActualGain(uiValue) { + uiValue = parseFloat(uiValue || 0); + if (uiValue < 0) return (uiValue + 100) / 100; // -100 to 0 -> 0.0 to 1.0 + if (uiValue > 0) return 1 + (uiValue / 100) * 3; // 0 to +100 -> 1.0 to 4.0 + return 1.0; + } + + setUserGain(userId, uiValue) { userId = String(userId); - this.userGains[userId] = parseFloat(volume); + this.userGains[userId] = parseFloat(uiValue); localStorage.setItem("voice_user_gains", JSON.stringify(this.userGains)); - if (this.remoteGainNodes[userId]) { - this.remoteGainNodes[userId].gain.setTargetAtTime(this.userGains[userId], this.audioContext.currentTime, 0.01); + + const actualGain = this.calculateActualGain(uiValue); + if (this.remoteGainNodes[userId] && this.audioContext) { + this.remoteGainNodes[userId].gain.setTargetAtTime(actualGain, this.audioContext.currentTime, 0.01); } } @@ -591,7 +615,6 @@ class VoiceChannel { if (this.currentChannelId && this.localStream) { const constraints = { audio: this.getAudioConstraints(), video: false }; try { - console.log('Updating audio constraints:', constraints); const newStream = await navigator.mediaDevices.getUserMedia(constraints); this.localStream.getTracks().forEach(t => t.stop()); this.localStream = newStream; @@ -636,9 +659,10 @@ class VoiceChannel { if (this.rnnoiseNode) { try { this.rnnoiseNode.disconnect(); } catch(e) {} } if (this.inputGainNode) { try { this.inputGainNode.disconnect(); } catch(e) {} } if (this.microphone) { try { this.microphone.disconnect(); } catch(e) {} } + if (this.analyser) { try { this.analyser.disconnect(); } catch(e) {} } 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.remoteGainNodes = {}; this.participants = {}; this.currentChannelId = null; this.myPeerId = null; this.speakingUsers.clear(); + this.peers = {}; this.remoteAudios = {}; this.remoteGainNodes = {}; this.participants = {}; this.peerToUserMap = {}; this.currentChannelId = null; this.myPeerId = null; this.speakingUsers.clear(); document.querySelectorAll('.voice-item').forEach(el => el.classList.remove('active')); this.updateVoiceUI(); } @@ -657,7 +681,7 @@ class VoiceChannel {
`; const sidebar = document.querySelector('.channels-sidebar'); @@ -706,8 +730,8 @@ class VoiceChannel { const listEl = container.querySelector('.voice-users-list'); if (listEl) { data.channels[channelId].forEach(p => { - const pid = String(p.user_id); - const isSpeaking = window.voiceHandler && window.voiceHandler.speakingUsers.has(pid); + const uid = String(p.user_id); + const isSpeaking = window.voiceHandler && window.voiceHandler.speakingUsers.has(uid); VoiceChannel.renderUserToUI(listEl, p.user_id, p.display_name || p.username, p.avatar_url, isSpeaking, p.is_muted, p.is_deafened); }); } @@ -739,7 +763,7 @@ class VoiceChannel { } -// Individual Gain UI Logic +// Individual Gain UI Logic (Boost/Cut -100 to +100) document.addEventListener('click', (e) => { const voiceUser = e.target.closest('.voice-user'); if (!voiceUser) { @@ -765,19 +789,18 @@ document.addEventListener('click', (e) => { popover.style.borderColor = '#313338'; popover.style.color = '#dbdee1'; - const currentGain = (window.voiceHandler && window.voiceHandler.userGains[userId] !== undefined) ? window.voiceHandler.userGains[userId] : 1.0; - const percent = Math.round(currentGain * 100); + const uiGain = (window.voiceHandler && window.voiceHandler.userGains[userId] !== undefined) ? window.voiceHandler.userGains[userId] : 0; popover.innerHTML = `
- Volume - ${percent}% + Boost/Cut + ${uiGain > 0 ? '+' : ''}${uiGain}
- +
- 0% - 100% - 400% + -100 + 0 + +100
`; @@ -788,14 +811,14 @@ document.addEventListener('click', (e) => { const display = popover.querySelector('.gain-value'); slider.oninput = () => { - const val = parseFloat(slider.value); - display.innerText = Math.round(val * 100) + '%'; + const val = parseInt(slider.value); + display.innerText = (val > 0 ? '+' : '') + val; if (window.voiceHandler) window.voiceHandler.setUserGain(userId, val); }; popover.querySelector('.btn-reset-gain').onclick = () => { - slider.value = 1.0; - display.innerText = '100%'; - if (window.voiceHandler) window.voiceHandler.setUserGain(userId, 1.0); + slider.value = 0; + display.innerText = '0'; + if (window.voiceHandler) window.voiceHandler.setUserGain(userId, 0); }; }); \ No newline at end of file