Release V1.7.5
This commit is contained in:
parent
7696bf079c
commit
2ba9d8d8ff
@ -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++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user