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.wasmInstance = null;
this.rnnoise = null; this.rnnoise = null;
this.initialized = false; 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.bufferPtr = 0;
this.heapInputPtr = null; this.heapInputPtr = null;
this.heapOutputPtr = null; this.heapOutputPtr = null;
this.statePtr = null; this.statePtr = null;
this.vadThreshold = 0.85; // Default strict threshold this.vadThreshold = 0.85;
this.enabled = true; this.enabled = true;
this.port.onmessage = (event) => { this.port.onmessage = (event) => {
@ -40,16 +52,12 @@ class RNNoiseProcessor extends AudioWorkletProcessor {
this.wasmInstance = wasmModule.instance; this.wasmInstance = wasmModule.instance;
this.rnnoise = this.wasmInstance.exports; 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); this.statePtr = this.rnnoise.rnnoise_create(0);
this.heapInputPtr = this.rnnoise.malloc(this.FRAME_SIZE * 4);
// Buffer size is 480 floats (4 bytes each) this.heapOutputPtr = this.rnnoise.malloc(this.FRAME_SIZE * 4);
this.heapInputPtr = this.rnnoise.malloc(480 * 4);
this.heapOutputPtr = this.rnnoise.malloc(480 * 4);
this.initialized = true; this.initialized = true;
console.log("RNNoise Processor Initialized with WASM"); console.log("RNNoise Processor Initialized");
} catch (e) { } catch (e) {
console.error("Failed to initialize RNNoise WASM:", e); console.error("Failed to initialize RNNoise WASM:", e);
} }
@ -59,55 +67,56 @@ class RNNoiseProcessor extends AudioWorkletProcessor {
const input = inputs[0]; const input = inputs[0];
const output = outputs[0]; const output = outputs[0];
if (!input || !input[0] || !this.initialized || !this.enabled) { if (!input || !input[0] || !output || !output[0]) return true;
// Pas d'entrée ou pas initialisé, on bypass
if (input && input[0] && output && output[0]) { if (!this.initialized || !this.enabled) {
output[0].set(input[0]); output[0].set(input[0]);
}
return true; return true;
} }
const inputChannel = input[0]; const inputChannel = input[0];
const outputChannel = output[0]; const outputChannel = output[0];
// Fill our internal buffer until we have 480 samples
for (let i = 0; i < inputChannel.length; i++) { 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++; this.bufferPtr++;
if (this.bufferPtr >= 480) { if (this.bufferPtr >= this.FRAME_SIZE) {
// Process 480 samples
this.processRNNoise(); this.processRNNoise();
this.bufferPtr = 0; this.bufferPtr = 0;
} }
// Output from our buffer (with latency of 480 samples) // Read from circular output buffer if available, else output silence/last
// We use a simple circular buffer approach here for the output too if (this.outCount > 0) {
outputChannel[i] = (this.outputBuffer[this.bufferPtr] / 32768.0); outputChannel[i] = this.outCircleBuffer[this.outReadPtr];
this.outReadPtr = (this.outReadPtr + 1) % this.outCircleBuffer.length;
this.outCount--;
} else {
outputChannel[i] = 0;
}
} }
return true; return true;
} }
processRNNoise() { processRNNoise() {
// Copy input to WASM heap const heapInput = new Float32Array(this.rnnoise.memory.buffer, this.heapInputPtr, this.FRAME_SIZE);
const heapInput = new Float32Array(this.rnnoise.memory.buffer, this.heapInputPtr, 480);
heapInput.set(this.inputBuffer); 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); 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, this.FRAME_SIZE);
const heapOutput = new Float32Array(this.rnnoise.memory.buffer, this.heapOutputPtr, 480);
// Aggressive Voice Gate based on RNNoise VAD // Write to circular output buffer
if (vadProbability < this.vadThreshold) { for (let i = 0; i < this.FRAME_SIZE; i++) {
// Not voice -> Mute completely let sample = heapOutput[i] / 32768.0;
this.outputBuffer.fill(0); // Apply gate
} else { if (vadProbability < this.vadThreshold) sample = 0;
// Voice detected -> Copy denoised output
this.outputBuffer.set(heapOutput); 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.localStream = null;
this.processedStream = null; this.processedStream = null;
this.analysisStream = null; this.analysisStream = null;
this.peers = {}; // userId -> RTCPeerConnection this.peers = {}; // peer_id -> RTCPeerConnection
this.participants = {}; // userId -> {name} this.participants = {}; // peer_id -> {user_id, name, ...}
this.peerToUserMap = {}; // peer_id -> user_id (as string)
this.currentChannelId = null; this.currentChannelId = null;
this.myPeerId = null; this.myPeerId = null;
this.pollInterval = null; this.pollInterval = null;
this.canSpeak = true; this.canSpeak = true;
this.remoteAudios = {}; // userId -> Audio element this.remoteAudios = {}; // user_id -> Audio element
this.isSelfMuted = false; this.isSelfMuted = false;
this.isDeafened = false; this.isDeafened = false;
this.peerStates = {}; // userId -> { makingOffer, ignoreOffer } this.peerStates = {}; // peer_id -> { makingOffer, ignoreOffer }
this.whisperSettings = []; // from DB this.whisperSettings = []; // from DB
this.whisperPeers = new Set(); // active whisper target peer_ids this.whisperPeers = new Set(); // active whisper target peer_ids
@ -51,8 +52,8 @@ class VoiceChannel {
this.lastVoiceTime = 0; this.lastVoiceTime = 0;
this.voxHoldTime = 400; this.voxHoldTime = 400;
this.speakingUsers = new Set(); this.speakingUsers = new Set(); // user_id (as string)
this.remoteGainNodes = {}; // userId -> GainNode this.remoteGainNodes = {}; // user_id -> GainNode
this.userGains = JSON.parse(localStorage.getItem("voice_user_gains") || "{}"); this.userGains = JSON.parse(localStorage.getItem("voice_user_gains") || "{}");
this.setupPTTListeners(); this.setupPTTListeners();
@ -81,15 +82,15 @@ class VoiceChannel {
return { return {
echoCancellation: ec, echoCancellation: ec,
noiseSuppression: ns, noiseSuppression: ns,
autoGainControl: false, autoGainControl: true,
googEchoCancellation: ec, googEchoCancellation: ec,
googAutoGainControl: false, googAutoGainControl: true,
googNoiseSuppression: ns, googNoiseSuppression: ns,
googHighpassFilter: useAdvanced, googHighpassFilter: useAdvanced,
googTypingNoiseDetection: true, googTypingNoiseDetection: true,
googAudioMirroring: false, googAudioMirroring: false,
googNoiseReduction: ns, googNoiseReduction: ns,
googAutoGainControl2: false, googAutoGainControl2: true,
channelCount: 1, channelCount: 1,
sampleRate: 48000, sampleRate: 48000,
sampleSize: 16, sampleSize: 16,
@ -210,6 +211,15 @@ class VoiceChannel {
this.myPeerId = data.peer_id; this.myPeerId = data.peer_id;
this.canSpeak = data.can_speak !== false; this.canSpeak = data.can_speak !== false;
sessionStorage.setItem('activeVoicePeerId', this.myPeerId); 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.startPolling();
this.updateVoiceUI(); this.updateVoiceUI();
} }
@ -236,18 +246,26 @@ class VoiceChannel {
const oldPs = Object.keys(this.participants); const oldPs = Object.keys(this.participants);
this.participants = data.participants; this.participants = data.participants;
const newPs = Object.keys(this.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 => { newPs.forEach(pid => {
if (pid !== this.myPeerId && !this.peers[pid]) this.createPeerConnection(pid, true); if (pid !== this.myPeerId && !this.peers[pid]) this.createPeerConnection(pid, true);
}); });
oldPs.forEach(pid => { 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(); this.peers[pid].close();
delete this.peers[pid]; delete this.peers[pid];
if (this.remoteAudios[pid]) { if (uid && this.remoteAudios[uid]) {
this.remoteAudios[pid].pause(); this.remoteAudios[uid].pause();
this.remoteAudios[pid].srcObject = null; this.remoteAudios[uid].srcObject = null;
this.remoteAudios[pid].remove(); this.remoteAudios[uid].remove();
delete this.remoteAudios[pid]; 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))}`); 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) { createPeerConnection(peerId, isOfferor) {
if (this.peers[userId]) return this.peers[userId]; if (this.peers[peerId]) return this.peers[peerId];
if (!this.peerStates[userId]) this.peerStates[userId] = { makingOffer: false, ignoreOffer: false }; if (!this.peerStates[peerId]) this.peerStates[peerId] = { makingOffer: false, ignoreOffer: false };
const pc = new RTCPeerConnection({ const pc = new RTCPeerConnection({
iceServers: [ iceServers: [
@ -275,15 +293,15 @@ class VoiceChannel {
{ urls: 'stun:stun2.l.google.com:19302' } { urls: 'stun:stun2.l.google.com:19302' }
] ]
}); });
this.peers[userId] = pc; this.peers[peerId] = pc;
pc.onnegotiationneeded = async () => { pc.onnegotiationneeded = async () => {
try { try {
this.peerStates[userId].makingOffer = true; this.peerStates[peerId].makingOffer = true;
await pc.setLocalDescription(); 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); } } 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; const streamToUse = this.processedStream || this.localStream;
@ -292,11 +310,17 @@ class VoiceChannel {
} }
pc.onicecandidate = (event) => { 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) => { pc.ontrack = (event) => {
const stream = event.streams[0] || new MediaStream([event.track]); 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) { if (!this.audioContext) {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ this.audioContext = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: 48000, sampleRate: 48000,
@ -315,9 +339,8 @@ class VoiceChannel {
const gainNode = this.audioContext.createGain(); const gainNode = this.audioContext.createGain();
const destination = this.audioContext.createMediaStreamDestination(); const destination = this.audioContext.createMediaStreamDestination();
// Initialiser le gain de l utilisateur const uiGain = this.userGains[userId] !== undefined ? parseFloat(this.userGains[userId]) : 0;
const userGain = this.userGains[userId] !== undefined ? parseFloat(this.userGains[userId]) : 1.0; gainNode.gain.value = this.calculateActualGain(uiGain);
gainNode.gain.value = userGain;
source.connect(gainNode); source.connect(gainNode);
gainNode.connect(destination); gainNode.connect(destination);
@ -342,7 +365,7 @@ class VoiceChannel {
} }
async handleSignaling(sig) { async handleSignaling(sig) {
const from = sig.from; const from = sig.from;
const data = sig.data; const data = sig.data;
try { try {
switch (data.type) { switch (data.type) {
@ -401,15 +424,13 @@ class VoiceChannel {
} catch (e) { console.error('Failed to load RNNoise:', e); } } catch (e) { console.error('Failed to load RNNoise:', e); }
} }
// Cleanup old nodes [this.scriptProcessor, this.rnnoiseNode, this.inputGainNode, this.microphone, this.analyser].forEach(node => {
[this.scriptProcessor, this.rnnoiseNode, this.inputGainNode, this.microphone].forEach(node => {
if (node) { try { node.disconnect(); } catch(e) {} } if (node) { try { node.disconnect(); } catch(e) {} }
}); });
this.microphone = this.audioContext.createMediaStreamSource(this.localStream); this.microphone = this.audioContext.createMediaStreamSource(this.localStream);
let lastNode = this.microphone; let lastNode = this.microphone;
// 1. Noise Suppression (RNNoise) FIRST - so it processes non-amplified signal
if (this.rnnoiseLoaded && this.settings.noiseSuppression) { if (this.rnnoiseLoaded && this.settings.noiseSuppression) {
console.log('Activating RNNoise suppression node (PRE-GAIN)'); console.log('Activating RNNoise suppression node (PRE-GAIN)');
this.rnnoiseNode = new AudioWorkletNode(this.audioContext, 'rnnoise-processor'); this.rnnoiseNode = new AudioWorkletNode(this.audioContext, 'rnnoise-processor');
@ -419,27 +440,22 @@ class VoiceChannel {
lastNode = this.rnnoiseNode; lastNode = this.rnnoiseNode;
} }
// 2. Input Gain (Amplification) AFTER Noise Suppression
this.inputGainNode = this.audioContext.createGain(); this.inputGainNode = this.audioContext.createGain();
this.inputGainNode.gain.value = this.isSelfMuted ? 0 : (this.settings.inputVolume || 1.0); this.inputGainNode.gain.value = this.isSelfMuted ? 0 : (this.settings.inputVolume || 1.0);
lastNode.connect(this.inputGainNode); lastNode.connect(this.inputGainNode);
lastNode = this.inputGainNode; lastNode = this.inputGainNode;
// Destination for processed stream
const destination = this.audioContext.createMediaStreamDestination(); const destination = this.audioContext.createMediaStreamDestination();
lastNode.connect(destination); lastNode.connect(destination);
this.processedStream = destination.stream; this.processedStream = destination.stream;
// Update peer tracks
Object.values(this.peers).forEach(pc => { Object.values(this.peers).forEach(pc => {
const sender = pc.getSenders().find(s => s.track && s.track.kind === 'audio'); const sender = pc.getSenders().find(s => s.track && s.track.kind === 'audio');
if (sender) { if (sender) {
console.log('Replacing track for peer to use processed (denoised) stream');
sender.replaceTrack(this.processedStream.getAudioTracks()[0]); sender.replaceTrack(this.processedStream.getAudioTracks()[0]);
} }
}); });
// Analysis for VOX (on the FINAL processed signal)
this.analyser = this.audioContext.createAnalyser(); this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 1024; this.analyser.fftSize = 1024;
this.analyser.smoothingTimeConstant = 0.3; this.analyser.smoothingTimeConstant = 0.3;
@ -471,6 +487,9 @@ class VoiceChannel {
} }
} }
}; };
this.applyAudioState();
} catch (e) { console.error('Failed setupVOX:', e); } } catch (e) { console.error('Failed setupVOX:', e); }
} }
@ -482,7 +501,6 @@ class VoiceChannel {
if (this.canSpeak === false) shouldTalk = false; if (this.canSpeak === false) shouldTalk = false;
if (this.isWhispering) shouldTalk = true; if (this.isWhispering) shouldTalk = true;
// Transmission is only possible if NOT self-muted
const shouldTransmit = !this.isSelfMuted && shouldTalk; const shouldTransmit = !this.isSelfMuted && shouldTalk;
if (this.isTalking !== shouldTransmit || this.lastWhisperState !== this.isWhispering) { if (this.isTalking !== shouldTransmit || this.lastWhisperState !== this.isWhispering) {
@ -490,7 +508,6 @@ class VoiceChannel {
this.lastWhisperState = this.isWhispering; this.lastWhisperState = this.isWhispering;
this.applyAudioState(); this.applyAudioState();
// Only update UI and send signals if we are actually transmitting
this.updateSpeakingUI(window.currentUserId, shouldTransmit, this.isWhispering); 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 }; 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 => { Object.keys(this.peers).forEach(pid => {
@ -506,7 +523,6 @@ class VoiceChannel {
const streamToUse = this.processedStream || this.localStream; const streamToUse = this.processedStream || this.localStream;
const shouldTransmit = !this.isSelfMuted && this.isTalking && (this.canSpeak || this.isWhispering); 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) { if (this.inputGainNode && this.audioContext) {
const targetGain = this.isSelfMuted ? 0 : (this.settings.inputVolume || 1.0); const targetGain = this.isSelfMuted ? 0 : (this.settings.inputVolume || 1.0);
this.inputGainNode.gain.setTargetAtTime(targetGain, this.audioContext.currentTime, 0.01); this.inputGainNode.gain.setTargetAtTime(targetGain, this.audioContext.currentTime, 0.01);
@ -516,11 +532,10 @@ class VoiceChannel {
streamToUse.getAudioTracks().forEach(track => { track.enabled = shouldTransmit; }); streamToUse.getAudioTracks().forEach(track => { track.enabled = shouldTransmit; });
Object.entries(this.peers).forEach(([pid, pc]) => { Object.entries(this.peers).forEach(([pid, pc]) => {
const sender = pc.getSenders().find(s => s.track && s.track.kind === 'audio'); const sender = pc.getSenders().find(s => s.track && s.track.kind === 'audio');
if (sender) { if (sender && sender.track) {
if (this.isWhispering) { if (this.isWhispering) {
sender.track.enabled = shouldTransmit && this.whisperPeers.has(pid); sender.track.enabled = shouldTransmit && this.whisperPeers.has(pid);
} else { } else {
// FIX: Ensure track is only enabled if shouldTransmit is true
sender.track.enabled = shouldTransmit && !!this.participants[pid]; sender.track.enabled = shouldTransmit && !!this.participants[pid];
} }
} }
@ -532,7 +547,7 @@ class VoiceChannel {
setMute(mute) { setMute(mute) {
this.isSelfMuted = mute; this.isSelfMuted = mute;
this.updateMuteState(); this.updateMuteState();
this.applyAudioState(); // Always update UI even if not "talking" this.applyAudioState();
} }
toggleMute() { if (this.canSpeak !== false) this.setMute(!this.isSelfMuted); } toggleMute() { if (this.canSpeak !== false) this.setMute(!this.isSelfMuted); }
toggleDeafen() { toggleDeafen() {
@ -552,12 +567,21 @@ class VoiceChannel {
Object.values(this.remoteAudios).forEach(audio => { audio.volume = this.settings.outputVolume; }); 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); userId = String(userId);
this.userGains[userId] = parseFloat(volume); this.userGains[userId] = parseFloat(uiValue);
localStorage.setItem("voice_user_gains", JSON.stringify(this.userGains)); 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) { if (this.currentChannelId && this.localStream) {
const constraints = { audio: this.getAudioConstraints(), video: false }; const constraints = { audio: this.getAudioConstraints(), video: false };
try { try {
console.log('Updating audio constraints:', constraints);
const newStream = await navigator.mediaDevices.getUserMedia(constraints); const newStream = await navigator.mediaDevices.getUserMedia(constraints);
this.localStream.getTracks().forEach(t => t.stop()); this.localStream.getTracks().forEach(t => t.stop());
this.localStream = newStream; this.localStream = newStream;
@ -636,9 +659,10 @@ class VoiceChannel {
if (this.rnnoiseNode) { try { this.rnnoiseNode.disconnect(); } catch(e) {} } if (this.rnnoiseNode) { try { this.rnnoiseNode.disconnect(); } catch(e) {} }
if (this.inputGainNode) { try { this.inputGainNode.disconnect(); } catch(e) {} } if (this.inputGainNode) { try { this.inputGainNode.disconnect(); } catch(e) {} }
if (this.microphone) { try { this.microphone.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.peers).forEach(pc => pc.close());
Object.values(this.remoteAudios).forEach(audio => { audio.pause(); audio.remove(); audio.srcObject = null; }); 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')); document.querySelectorAll('.voice-item').forEach(el => el.classList.remove('active'));
this.updateVoiceUI(); this.updateVoiceUI();
} }
@ -657,7 +681,7 @@ class VoiceChannel {
</div> </div>
<div> <div>
<button class="btn btn-sm text-muted" id="btn-voice-leave" title="Disconnect"> <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> </button>
</div>`; </div>`;
const sidebar = document.querySelector('.channels-sidebar'); const sidebar = document.querySelector('.channels-sidebar');
@ -706,8 +730,8 @@ class VoiceChannel {
const listEl = container.querySelector('.voice-users-list'); const listEl = container.querySelector('.voice-users-list');
if (listEl) { if (listEl) {
data.channels[channelId].forEach(p => { data.channels[channelId].forEach(p => {
const pid = String(p.user_id); const uid = String(p.user_id);
const isSpeaking = window.voiceHandler && window.voiceHandler.speakingUsers.has(pid); 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); 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) => { document.addEventListener('click', (e) => {
const voiceUser = e.target.closest('.voice-user'); const voiceUser = e.target.closest('.voice-user');
if (!voiceUser) { if (!voiceUser) {
@ -765,19 +789,18 @@ document.addEventListener('click', (e) => {
popover.style.borderColor = '#313338'; popover.style.borderColor = '#313338';
popover.style.color = '#dbdee1'; popover.style.color = '#dbdee1';
const currentGain = (window.voiceHandler && window.voiceHandler.userGains[userId] !== undefined) ? window.voiceHandler.userGains[userId] : 1.0; const uiGain = (window.voiceHandler && window.voiceHandler.userGains[userId] !== undefined) ? window.voiceHandler.userGains[userId] : 0;
const percent = Math.round(currentGain * 100);
popover.innerHTML = ` popover.innerHTML = `
<div class="small fw-bold mb-2 d-flex justify-content-between"> <div class="small fw-bold mb-2 d-flex justify-content-between">
<span>Volume</span> <span>Boost/Cut</span>
<span class="gain-value">${percent}%</span> <span class="gain-value">${uiGain > 0 ? '+' : ''}${uiGain}</span>
</div> </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;"> <div class="d-flex justify-content-between mt-1 mb-2" style="font-size: 9px; color: #8e9297;">
<span>0%</span> <span>-100</span>
<span>100%</span> <span>0</span>
<span>400%</span> <span>+100</span>
</div> </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> <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'); const display = popover.querySelector('.gain-value');
slider.oninput = () => { slider.oninput = () => {
const val = parseFloat(slider.value); const val = parseInt(slider.value);
display.innerText = Math.round(val * 100) + '%'; display.innerText = (val > 0 ? '+' : '') + val;
if (window.voiceHandler) window.voiceHandler.setUserGain(userId, val); if (window.voiceHandler) window.voiceHandler.setUserGain(userId, val);
}; };
popover.querySelector('.btn-reset-gain').onclick = () => { popover.querySelector('.btn-reset-gain').onclick = () => {
slider.value = 1.0; slider.value = 0;
display.innerText = '100%'; display.innerText = '0';
if (window.voiceHandler) window.voiceHandler.setUserGain(userId, 1.0); if (window.voiceHandler) window.voiceHandler.setUserGain(userId, 0);
}; };
}); });