Release V1.7.5

This commit is contained in:
Flatlogic Bot 2026-03-30 09:50:00 +00:00
parent 7696bf079c
commit 2ba9d8d8ff
2 changed files with 131 additions and 99 deletions

View File

@ -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++;
}
}
}

View File

@ -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 {
</div>
<div>
<button class="btn btn-sm text-muted" id="btn-voice-leave" title="Disconnect">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91"></path><line x1="23" y1="1" x2="1" y2="23"></line></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67-19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91"></path><line x1="23" y1="1" x2="1" y2="23"></line></svg>
</button>
</div>`;
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 = `
<div class="small fw-bold mb-2 d-flex justify-content-between">
<span>Volume</span>
<span class="gain-value">${percent}%</span>
<span>Boost/Cut</span>
<span class="gain-value">${uiGain > 0 ? '+' : ''}${uiGain}</span>
</div>
<input type="range" class="form-range gain-slider" min="0" max="4" step="0.05" value="${currentGain}" style="cursor: pointer; height: 4px;">
<input type="range" class="form-range gain-slider" min="-100" max="100" step="1" value="${uiGain}" style="cursor: pointer; height: 4px;">
<div class="d-flex justify-content-between mt-1 mb-2" style="font-size: 9px; color: #8e9297;">
<span>0%</span>
<span>100%</span>
<span>400%</span>
<span>-100</span>
<span>0</span>
<span>+100</span>
</div>
<button class="btn btn-sm btn-secondary w-100 mt-1 btn-reset-gain" style="font-size: 11px; background-color: #4e5058; border: none;">Reset</button>
`;
@ -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);
};
});