diff --git a/Temp/check_divs.php b/Temp/check_divs.php deleted file mode 100644 index 3ef77f2..0000000 --- a/Temp/check_divs.php +++ /dev/null @@ -1,19 +0,0 @@ - $line) { - $line_open = substr_count($line, '= 760 && ($i + 1) <= 780) { - echo "Line " . ($i + 1) . ": Depth $old_depth -> $depth | " . trim($line) . "\n"; - } -} -echo "Final depth: $depth\n"; diff --git a/Temp/check_users.php b/Temp/check_users.php deleted file mode 100644 index d83595c..0000000 --- a/Temp/check_users.php +++ /dev/null @@ -1,13 +0,0 @@ -query("SELECT id, username, email FROM users"); - $users = $stmt->fetchAll(); - echo "Liste des utilisateurs :\n"; - foreach ($users as $user) { - echo "- ID: {$user['id']} | Username: {$user['username']} | Email: {$user['email']}\n"; - } -} catch (Exception $e) { - echo "Erreur : " . $e->getMessage(); -} -unlink(__FILE__); diff --git a/Temp/create_admin.php b/Temp/create_admin.php deleted file mode 100644 index 1a2f080..0000000 --- a/Temp/create_admin.php +++ /dev/null @@ -1,33 +0,0 @@ -prepare("SELECT id FROM users WHERE email = ? OR username = ?"); - $stmt->execute([$email, $username]); - - if ($stmt->fetch()) { - echo "L'utilisateur admin ou cet email existe déjà."; - } else { - $stmt = $pdo->prepare("INSERT INTO users (username, display_name, email, password_hash, status, is_admin) VALUES (?, ?, ?, ?, ?, ?)"); - $stmt->execute([$username, 'Administrateur', $email, $password_hash, 'offline', 1]); - - echo "

Succès !

"; - echo "

Compte administrateur créé avec succès.

"; - echo ""; - echo "

IMPORTANT : Supprimez ce fichier (create_admin.php) immédiatement après utilisation pour des raisons de sécurité.

"; - } -} catch (PDOException $e) { - echo "Erreur lors de la création du compte : " . $e->getMessage(); -} -?> diff --git a/Temp/db_init.php b/Temp/db_init.php deleted file mode 100644 index 9522d8a..0000000 --- a/Temp/db_init.php +++ /dev/null @@ -1,10 +0,0 @@ -exec($sql); - echo "Database initialized successfully.\n"; -} catch (Exception $e) { - echo "Error initializing database: " . $e->getMessage() . "\n"; -} diff --git a/Temp/diagnostic.php b/Temp/diagnostic.php deleted file mode 100644 index 87d80ef..0000000 --- a/Temp/diagnostic.php +++ /dev/null @@ -1,62 +0,0 @@ -Diagnostic du Projet Corvara"; - -// 1. Test de la connexion -try { - $pdo = db(); - echo "

✅ Connexion à la base de données réussie.

"; -} catch (Exception $e) { - echo "

❌ Erreur de connexion : " . htmlspecialchars($e->getMessage()) . "

"; - exit; -} - -// 2. Vérification des tables -$required_tables = [ - 'users', 'servers', 'channels', 'messages', 'roles', 'server_members', - 'channel_members', 'channel_events', 'poll_votes', 'server_badges', - 'member_badges', 'custom_emotes', 'voice_sessions' -]; - -echo "

Vérification des tables :

"; - -// 3. Extensions PHP -echo "

Extensions PHP :

"; - -// 4. Vérification de l'administrateur -echo "

Compte Administrateur :

"; -try { - $stmt = $pdo->prepare("SELECT id FROM users WHERE email = ? AND is_admin = 1"); - $stmt->execute(['admin@corvara.com']); - $admin = $stmt->fetch(); - if ($admin) { - echo "

✅ SuperAdmin (admin@corvara.com) présent.

"; - } else { - echo "

⚠️ SuperAdmin absent. Utilisez create_admin.php pour le créer.

"; - } -} catch (Exception $e) { - echo "

Impossible de vérifier l'admin (table users manquante ?).

"; -} - -echo "

Si des tables sont manquantes, exécutez fix_db.php.

"; diff --git a/Temp/fix_db.php b/Temp/fix_db.php deleted file mode 100644 index 142982f..0000000 --- a/Temp/fix_db.php +++ /dev/null @@ -1,103 +0,0 @@ -Réparation de la base de données"; - -try { - $pdo = db(); -} catch (Exception $e) { - die("

Erreur de connexion : " . $e->getMessage() . "

"); -} - -$tables = [ - "CREATE TABLE IF NOT EXISTS `channel_events` ( - `id` int(11) NOT NULL AUTO_INCREMENT, - `channel_id` int(11) NOT NULL, - `user_id` int(11) NOT NULL, - `title` varchar(255) NOT NULL, - `description` text DEFAULT NULL, - `banner_url` varchar(255) DEFAULT NULL, - `banner_color` varchar(20) DEFAULT NULL, - `start_date` date NOT NULL, - `start_time` time NOT NULL, - `end_date` date NOT NULL, - `end_time` time NOT NULL, - `frequency` varchar(50) DEFAULT NULL, - `is_permanent` tinyint(1) DEFAULT 0, - `created_at` timestamp NULL DEFAULT current_timestamp(), - `enable_reactions` tinyint(1) DEFAULT 0, - PRIMARY KEY (`id`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;", - - "CREATE TABLE IF NOT EXISTS `poll_votes` ( - `id` int(11) NOT NULL AUTO_INCREMENT, - `message_id` int(11) NOT NULL, - `user_id` int(11) NOT NULL, - `option_index` int(11) NOT NULL, - `created_at` timestamp NULL DEFAULT current_timestamp(), - PRIMARY KEY (`id`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;", - - "CREATE TABLE IF NOT EXISTS `server_badges` ( - `id` int(11) NOT NULL AUTO_INCREMENT, - `server_id` int(11) NOT NULL, - `name` varchar(255) NOT NULL, - `image_url` text NOT NULL, - `created_at` timestamp NULL DEFAULT current_timestamp(), - PRIMARY KEY (`id`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;", - - "CREATE TABLE IF NOT EXISTS `member_badges` ( - `server_id` int(11) NOT NULL, - `user_id` int(11) NOT NULL, - `badge_id` int(11) NOT NULL, - PRIMARY KEY (`server_id`,`user_id`,`badge_id`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;", - - "CREATE TABLE IF NOT EXISTS `event_participations` ( - `id` int(11) NOT NULL AUTO_INCREMENT, - `event_id` int(11) NOT NULL, - `user_id` int(11) NOT NULL, - `created_at` timestamp NULL DEFAULT current_timestamp(), - PRIMARY KEY (`id`), - UNIQUE KEY `event_id` (`event_id`,`user_id`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;" -]; - -foreach ($tables as $sql) { - try { - $pdo->exec($sql); - echo "

✅ Exécution réussie d'une requête de création.

"; - } catch (Exception $e) { - echo "

⚠️ Info : " . $e->getMessage() . " (Peut-être que la table existe déjà)

"; - } -} - -echo "

Vérification finale des colonnes (Correction Erreur 500 courante)

"; -// Ajout de colonnes si manquantes (exemple pour channels) -try { - $pdo->exec("ALTER TABLE `channels` ADD COLUMN IF NOT EXISTS `message_limit` int(11) DEFAULT NULL;"); - $pdo->exec("ALTER TABLE `channels` ADD COLUMN IF NOT EXISTS `theme_color` varchar(20) DEFAULT NULL;"); - echo "

✅ Mise à jour des colonnes 'channels' terminée.

"; -} catch (Exception $e) { - echo "

ℹ️ Colonnes déjà présentes dans 'channels'.

"; -} - -// Ajout du SuperAdmin si absent -try { - $stmt = $pdo->prepare("SELECT id FROM users WHERE email = ?"); - $stmt->execute(['admin@corvara.com']); - if (!$stmt->fetch()) { - $hash = '$2y$10$wvwjJlj0mKf47YRzwsxom./X0w1BX9NDqVnL40D97QGe7oPjdiL5i'; - $pdo->prepare("INSERT INTO users (username, display_name, email, password_hash, is_admin) VALUES (?, ?, ?, ?, 1)") - ->execute(['admin', 'SuperAdmin', 'admin@corvara.com', $hash]); - echo "

✅ Compte SuperAdmin créé (admin@corvara.com / admin123).

"; - } -} catch (Exception $e) { - echo "

⚠️ Impossible de créer l'admin automatique : " . $e->getMessage() . "

"; -} - -echo "

Terminé ! Essayez de rafraîchir votre page index.php.

"; -echo "

IMPORTANT : Supprimez ce fichier (fix_db.php) après usage.

"; diff --git a/Temp/test_poll.php b/Temp/test_poll.php deleted file mode 100644 index 2d470eb..0000000 --- a/Temp/test_poll.php +++ /dev/null @@ -1,30 +0,0 @@ - 1, - 'content' => 'bha a vous de choisir !', - 'is_poll' => '1', - 'poll_title' => 'Test du sondage !', - 'poll_color' => '#f45571', - 'poll_choice_type' => 'single', - 'poll_end_date' => '20/02/2026 23:45', - 'poll_options' => '["Oui je le veux !","Non je ne le veux pas !","Je ne sais pas encore !"]' -]; - -function getallheaders() { return []; } - -// Mock session -if (session_status() === PHP_SESSION_NONE) { - session_start(); -} -$_SESSION['user_id'] = 2; - -// Include the file -ob_start(); -include 'api_v1_messages.php'; -$output = ob_get_clean(); - -echo $output; diff --git a/assets/js/rnnoise-processor.js b/assets/js/rnnoise-processor.js new file mode 100644 index 0000000..15c8ec0 --- /dev/null +++ b/assets/js/rnnoise-processor.js @@ -0,0 +1,115 @@ +// RNNoise Web Audio Worklet Processor +// Uses the RNNoise WASM binary to perform real-time noise suppression + +class RNNoiseProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this.alive = true; + 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); + this.bufferPtr = 0; + this.heapInputPtr = null; + this.heapOutputPtr = null; + this.statePtr = null; + + this.vadThreshold = 0.85; // Default strict threshold + this.enabled = true; + + this.port.onmessage = (event) => { + if (event.data.type === 'INIT') { + this.initWasm(event.data.wasmBinary); + } else if (event.data.type === 'SET_ENABLED') { + this.enabled = event.data.enabled; + } else if (event.data.type === 'SET_THRESHOLD') { + this.vadThreshold = event.data.value; + } + }; + } + + async initWasm(wasmBinary) { + try { + const wasmModule = await WebAssembly.instantiate(wasmBinary, { + env: { + memory: new WebAssembly.Memory({ initial: 256, maximum: 256 }), + abort: () => { console.error("WASM Abort"); } + } + }); + 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.initialized = true; + console.log("RNNoise Processor Initialized with WASM"); + } catch (e) { + console.error("Failed to initialize RNNoise WASM:", e); + } + } + + process(inputs, outputs, parameters) { + 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]); + } + 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 + this.bufferPtr++; + + if (this.bufferPtr >= 480) { + // Process 480 samples + 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); + } + + return true; + } + + processRNNoise() { + // Copy input to WASM heap + const heapInput = new Float32Array(this.rnnoise.memory.buffer, this.heapInputPtr, 480); + 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); + + // 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); + } + } +} + +registerProcessor('rnnoise-processor', RNNoiseProcessor); \ No newline at end of file diff --git a/assets/js/rnnoise.wasm b/assets/js/rnnoise.wasm new file mode 100644 index 0000000..7de4dc3 Binary files /dev/null and b/assets/js/rnnoise.wasm differ diff --git a/assets/js/voice.js b/assets/js/voice.js index 044472d..afec449 100644 --- a/assets/js/voice.js +++ b/assets/js/voice.js @@ -9,14 +9,15 @@ class VoiceChannel { voxThreshold: 0.1, inputDevice: 'default', outputDevice: 'default', - inputVolume: 1.0, - outputVolume: 1.0, + inputVolume: parseFloat(localStorage.getItem('voice_input_volume') || 1.0), + outputVolume: parseFloat(localStorage.getItem('voice_output_volume') || 1.0), echoCancellation: true, noiseSuppression: true, advancedFilters: true }; console.log('VoiceChannel constructor called with settings:', this.settings); this.localStream = null; + this.processedStream = null; this.analysisStream = null; this.peers = {}; // userId -> RTCPeerConnection this.participants = {}; // userId -> {name} @@ -38,78 +39,69 @@ class VoiceChannel { this.audioContext = null; this.analyser = null; this.microphone = null; + this.inputGainNode = null; this.scriptProcessor = null; - this.inputGain = null; + this.rnnoiseNode = null; + this.rnnoiseLoaded = false; + this.rnnoiseWasmBinary = null; this.isTalking = false; this.pttPressed = false; this.voxActive = false; this.lastVoiceTime = 0; - this.voxHoldTime = 400; // Slightly shorter hold time for better responsiveness + this.voxHoldTime = 400; - // Track who is speaking to persist across UI refreshes this.speakingUsers = new Set(); this.setupPTTListeners(); this.loadWhisperSettings(); - // Auto-rejoin if we were in a channel setTimeout(() => { const savedChannelId = sessionStorage.getItem('activeVoiceChannel'); const savedPeerId = sessionStorage.getItem('activeVoicePeerId'); if (savedChannelId) { console.log('Auto-rejoining voice channel:', savedChannelId); if (savedPeerId) this.myPeerId = savedPeerId; - this.join(savedChannelId, true); // Pass true to indicate auto-rejoin + this.join(savedChannelId, true); } }, 200); } - // Alias for index.php compatibility set whisperParamètres(val) { this.whisperSettings = val; } getAudioConstraints() { const useAdvanced = this.settings.advancedFilters !== false; - - const constraints = { - echoCancellation: { ideal: this.settings.echoCancellation }, - noiseSuppression: { ideal: this.settings.noiseSuppression }, - autoGainControl: { ideal: useAdvanced }, - // Chromium-specific flags - googEchoCancellation: { ideal: this.settings.echoCancellation }, - googAutoGainControl: { ideal: useAdvanced }, - googNoiseSuppression: { ideal: this.settings.noiseSuppression }, - googHighpassFilter: { ideal: useAdvanced }, - googTypingNoiseDetection: { ideal: true }, - googAudioMirroring: { ideal: false }, - googNoiseReduction: { ideal: this.settings.noiseSuppression }, - googAutoGainControl2: { ideal: useAdvanced }, - // Standard constraints - channelCount: { ideal: 1 }, - sampleRate: { ideal: 48000 }, - sampleSize: { ideal: 16 } + const ns = !!this.settings.noiseSuppression; + const ec = !!this.settings.echoCancellation; + + return { + echoCancellation: ec, + noiseSuppression: ns, + autoGainControl: useAdvanced, + googEchoCancellation: ec, + googAutoGainControl: useAdvanced, + googNoiseSuppression: ns, + googHighpassFilter: useAdvanced, + googTypingNoiseDetection: true, + googAudioMirroring: false, + googNoiseReduction: ns, + googAutoGainControl2: useAdvanced, + channelCount: 1, + sampleRate: 48000, + sampleSize: 16, + latency: 0.005 }; - - if (this.settings.inputDevice && this.settings.inputDevice !== 'default') { - constraints.deviceId = { ideal: this.settings.inputDevice }; - } - - return constraints; } setupPTTListeners() { window.addEventListener('keydown', (e) => { - // Ignore if in input field if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; - - // Normal PTT if (this.settings.mode === 'ptt') { const isMatch = e.key.toLowerCase() === this.settings.pttKey.toLowerCase() || (e.code && e.code.toLowerCase() === this.settings.pttKey.toLowerCase()) || (this.settings.pttKey === '0' && e.code === 'Numpad0'); - if (isMatch) { if (!this.pttPressed) { this.pttPressed = true; @@ -118,29 +110,23 @@ class VoiceChannel { return; } } - - // Whispers this.whisperSettings.forEach(w => { if (e.key.toLowerCase() === w.whisper_key.toLowerCase()) { this.startWhisper(w); } }); }); - window.addEventListener('keyup', (e) => { if (this.settings.mode === 'ptt') { const isMatch = e.key.toLowerCase() === this.settings.pttKey.toLowerCase() || (e.code && e.code.toLowerCase() === this.settings.pttKey.toLowerCase()) || (this.settings.pttKey === '0' && e.code === 'Numpad0'); - if (isMatch) { this.pttPressed = false; this.updateMuteState(); return; } } - - // Whispers this.whisperSettings.forEach(w => { if (e.key.toLowerCase() === w.whisper_key.toLowerCase()) { this.stopWhisper(w); @@ -155,44 +141,32 @@ class VoiceChannel { const data = await resp.json(); if (data.success) { this.whisperSettings = data.whispers; - console.log('VoiceChannel: Loaded whispers:', this.whisperSettings); } } catch (e) { - console.error('Failed to load whispers in VoiceChannel:', e); + console.error('Failed to load whispers:', e); } } setupWhisperListeners() { - // This is called when settings are updated in the UI this.loadWhisperSettings(); } async startWhisper(config) { if (this.isWhispering) return; - console.log('Starting whisper to:', config.target_type, config.target_id); - try { const resp = await fetch(`api_v1_voice.php?action=find_whisper_targets&target_type=${config.target_type}&target_id=${config.target_id}`); const data = await resp.json(); - if (data.success && data.targets.length > 0) { this.isWhispering = true; this.whisperPeers.clear(); - for (const target of data.targets) { if (target.peer_id === this.myPeerId) continue; this.whisperPeers.add(target.peer_id); - - // Establish connection if not exists if (!this.peers[target.peer_id]) { - console.log('Establishing temporary connection for whisper to:', target.peer_id); this.createPeerConnection(target.peer_id, true); } } - this.updateMuteState(); - } else { - console.log('No active targets found for whisper.'); } } catch (e) { console.error('Whisper start error:', e); @@ -201,68 +175,45 @@ class VoiceChannel { stopWhisper(config) { if (!this.isWhispering) return; - console.log('Stopping whisper'); this.isWhispering = false; this.whisperPeers.clear(); this.updateMuteState(); } async join(channelId, isAutoRejoin = false) { - console.log('VoiceChannel.join process started for channel:', channelId, 'isAutoRejoin:', isAutoRejoin); - if (this.currentChannelId === channelId && !isAutoRejoin) { - console.log('Already in this channel'); - return; - } - if (this.currentChannelId && this.currentChannelId != channelId) { - console.log('Leaving previous channel:', this.currentChannelId); - this.leave(); - } + if (this.currentChannelId === channelId && !isAutoRejoin) return; + if (this.currentChannelId && this.currentChannelId != channelId) this.leave(); this.currentChannelId = channelId; sessionStorage.setItem('activeVoiceChannel', channelId); try { - console.log('Requesting microphone access with device:', this.settings.inputDevice); - const constraints = { - audio: this.getAudioConstraints(), - video: false - }; - + const constraints = { audio: this.getAudioConstraints(), video: false }; + console.log('Requesting mic with constraints:', constraints); try { this.localStream = await navigator.mediaDevices.getUserMedia(constraints); } catch (err) { - console.warn('Advanced constraints failed, falling back to basic audio:', err); + console.warn('GUM with constraints failed, falling back to basic audio', err); this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); } - console.log('Microphone access granted'); - this.setMute(false); // Join unmuted by default (self-mute off) + this.setMute(false); + await this.setupVOX(); - // Always setup VOX logic for volume meter and detection - this.setupVOX(); - - // Join via PHP - console.log('Calling API join...'); const url = `api_v1_voice.php?action=join&room=${channelId}&name=${encodeURIComponent(window.currentUsername || 'Unknown')}${this.myPeerId ? '&peer_id='+this.myPeerId : ''}`; const resp = await fetch(url); const data = await resp.json(); - console.log('API join response:', data); if (data.success) { this.myPeerId = data.peer_id; this.canSpeak = data.can_speak !== false; sessionStorage.setItem('activeVoicePeerId', this.myPeerId); - console.log('Joined room with peer_id:', this.myPeerId); - - // Start polling this.startPolling(); this.updateVoiceUI(); - } else { - console.error('API join failed:', data.error); } } catch (e) { console.error('Failed to join voice:', e); - alert('Microphone access required for voice channels. Error: ' + e.message); + alert('Microphone access required. Error: ' + e.message); this.currentChannelId = null; } } @@ -270,57 +221,40 @@ class VoiceChannel { startPolling() { if (this.pollInterval) clearInterval(this.pollInterval); this.pollInterval = setInterval(() => this.poll(), 500); - this.poll(); // Initial poll + this.poll(); } async poll() { if (!this.myPeerId || !this.currentChannelId) return; - try { const resp = await fetch(`api_v1_voice.php?action=poll&room=${this.currentChannelId}&peer_id=${this.myPeerId}&is_muted=${this.isSelfMuted ? 1 : 0}&is_deafened=${this.isDeafened ? 1 : 0}`); const data = await resp.json(); - if (data.success) { this.canSpeak = data.can_speak !== false; - // Update participants const oldPs = Object.keys(this.participants); this.participants = data.participants; const newPs = Object.keys(this.participants); - - // If new people joined, initiate offer newPs.forEach(pid => { - if (pid !== this.myPeerId && !this.peers[pid]) { - console.log('New peer found via poll:', pid); - this.createPeerConnection(pid, true); - } + if (pid !== this.myPeerId && !this.peers[pid]) this.createPeerConnection(pid, true); }); - - // Cleanup left peers oldPs.forEach(pid => { if (!this.participants[pid] && this.peers[pid] && !this.whisperPeers.has(pid) && !this.speakingUsers.has(pid)) { - console.log('Peer left or not in channel anymore:', 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]; } } }); - - // Handle incoming signals if (data.signals && data.signals.length > 0) { - for (const sig of data.signals) { - await this.handleSignaling(sig); - } + for (const sig of data.signals) await this.handleSignaling(sig); } - this.updateVoiceUI(); } - } catch (e) { - console.error('Polling error:', e); - } + } catch (e) { console.error('Polling error:', e); } } async sendSignal(to, data) { @@ -330,12 +264,7 @@ class VoiceChannel { createPeerConnection(userId, isOfferor) { if (this.peers[userId]) return this.peers[userId]; - - console.log('Creating PeerConnection for:', userId, 'as offeror:', isOfferor); - - if (!this.peerStates[userId]) { - this.peerStates[userId] = { makingOffer: false, ignoreOffer: false }; - } + if (!this.peerStates[userId]) this.peerStates[userId] = { makingOffer: false, ignoreOffer: false }; const pc = new RTCPeerConnection({ iceServers: [ @@ -344,51 +273,34 @@ class VoiceChannel { { urls: 'stun:stun2.l.google.com:19302' } ] }); - this.peers[userId] = pc; - pc.oniceconnectionstatechange = () => { - console.log(`ICE Connection State with ${userId}: ${pc.iceConnectionState}`); - }; - pc.onnegotiationneeded = async () => { try { this.peerStates[userId].makingOffer = true; await pc.setLocalDescription(); this.sendSignal(userId, { type: 'offer', offer: pc.localDescription }); - } catch (err) { - console.error('onnegotiationneeded error:', err); - } finally { - this.peerStates[userId].makingOffer = false; - } + } catch (err) { console.error('onnegotiationneeded error:', err); } + finally { this.peerStates[userId].makingOffer = false; } }; - if (this.localStream) { - this.localStream.getTracks().forEach(track => { - pc.addTrack(track, this.localStream); - }); + const streamToUse = this.processedStream || this.localStream; + if (streamToUse) { + streamToUse.getTracks().forEach(track => pc.addTrack(track, streamToUse)); } pc.onicecandidate = (event) => { - if (event.candidate) { - this.sendSignal(userId, { type: 'ice_candidate', candidate: event.candidate }); - } + if (event.candidate) this.sendSignal(userId, { type: 'ice_candidate', candidate: event.candidate }); }; pc.ontrack = (event) => { - console.log('Received remote track from:', userId); const stream = event.streams[0] || new MediaStream([event.track]); - - if (this.audioContext && this.audioContext.state === 'suspended') { - this.audioContext.resume(); - } - + if (this.audioContext && this.audioContext.state === 'suspended') this.audioContext.resume(); if (this.remoteAudios[userId]) { this.remoteAudios[userId].pause(); this.remoteAudios[userId].srcObject = null; this.remoteAudios[userId].remove(); } - const remoteAudio = new Audio(); remoteAudio.autoplay = true; remoteAudio.style.display = 'none'; @@ -400,41 +312,24 @@ class VoiceChannel { } document.body.appendChild(remoteAudio); this.remoteAudios[userId] = remoteAudio; - - remoteAudio.play().catch(e => { - console.warn('Autoplay prevented for:', userId, e); - }); + remoteAudio.play().catch(e => console.warn('Autoplay prevented:', userId, e)); }; - if (isOfferor && pc.signalingState === 'stable') { - pc.onnegotiationneeded(); - } - + if (isOfferor && pc.signalingState === 'stable') pc.onnegotiationneeded(); return pc; } async handleSignaling(sig) { const from = sig.from; const data = sig.data; - try { switch (data.type) { - case 'offer': - await this.handleOffer(from, data.offer); - break; - case 'answer': - await this.handleAnswer(from, data.answer); - break; - case 'ice_candidate': - await this.handleCandidate(from, data.candidate); - break; - case 'voice_speaking': - this.updateSpeakingUI(data.user_id, data.speaking, data.is_whisper); - break; + case 'offer': await this.handleOffer(from, data.offer); break; + case 'answer': await this.handleAnswer(from, data.answer); break; + case 'ice_candidate': await this.handleCandidate(from, data.candidate); break; + case 'voice_speaking': this.updateSpeakingUI(data.user_id, data.speaking, data.is_whisper); break; } - } catch (err) { - console.error('Signaling error:', err); - } + } catch (err) { console.error('Signaling error:', err); } } async handleOffer(from, offer) { @@ -443,9 +338,7 @@ class VoiceChannel { const offerCollision = (offer.type === "offer") && (state.makingOffer || pc.signalingState !== "stable"); const isPolite = this.myPeerId > from; state.ignoreOffer = !isPolite && offerCollision; - if (state.ignoreOffer) return; - await pc.setRemoteDescription(new RTCSessionDescription(offer)); if (offer.type === "offer") { await pc.setLocalDescription(); @@ -455,54 +348,82 @@ class VoiceChannel { async handleAnswer(from, answer) { const pc = this.peers[from]; - if (pc) { - await pc.setRemoteDescription(new RTCSessionDescription(answer)); - } + if (pc) await pc.setRemoteDescription(new RTCSessionDescription(answer)); } async handleCandidate(from, candidate) { const pc = this.peers[from]; - try { - if (pc) { - await pc.addIceCandidate(new RTCIceCandidate(candidate)); - } - } catch (err) {} + try { if (pc) await pc.addIceCandidate(new RTCIceCandidate(candidate)); } catch (err) {} } - setupVOX() { + async setupVOX() { if (!this.localStream) return; - try { if (!this.audioContext) { - this.audioContext = new (window.AudioContext || window.webkitAudioContext)(); + this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ + sampleRate: 48000, + latencyHint: 'interactive' + }); } - - if (this.audioContext.state === 'suspended') { - this.audioContext.resume(); + if (this.audioContext.state === 'suspended') await this.audioContext.resume(); + + if (this.settings.noiseSuppression && !this.rnnoiseLoaded) { + try { + console.log('Loading RNNoise module and WASM...'); + const version = Date.now(); + await this.audioContext.audioWorklet.addModule(`assets/js/rnnoise-processor.js?v=${version}`); + const resp = await fetch(`assets/js/rnnoise.wasm?v=${version}`); + this.rnnoiseWasmBinary = await resp.arrayBuffer(); + this.rnnoiseLoaded = true; + console.log('RNNoise loaded successfully'); + } catch (e) { console.error('Failed to load RNNoise:', e); } } - if (this.scriptProcessor) { - this.scriptProcessor.onaudioprocess = null; - try { this.scriptProcessor.disconnect(); } catch(e) {} - } - if (this.microphone) { - try { this.microphone.disconnect(); } catch(e) {} + // Cleanup old nodes + [this.scriptProcessor, this.rnnoiseNode, this.inputGainNode, this.microphone].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'); + this.rnnoiseNode.port.postMessage({ type: 'INIT', wasmBinary: this.rnnoiseWasmBinary }); + this.rnnoiseNode.port.postMessage({ type: 'SET_ENABLED', enabled: true }); + lastNode.connect(this.rnnoiseNode); + 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; // Better resolution - this.analyser.smoothingTimeConstant = 0.3; // Less jitter - - if (this.analysisStream) { - this.analysisStream.getTracks().forEach(t => t.stop()); - } - this.analysisStream = this.localStream.clone(); - this.analysisStream.getAudioTracks().forEach(t => t.enabled = true); - - this.microphone = this.audioContext.createMediaStreamSource(this.analysisStream); + this.analyser.fftSize = 1024; + this.analyser.smoothingTimeConstant = 0.3; this.scriptProcessor = this.audioContext.createScriptProcessor(2048, 1, 1); - this.microphone.connect(this.analyser); + lastNode.connect(this.analyser); this.analyser.connect(this.scriptProcessor); const silence = this.audioContext.createGain(); @@ -510,108 +431,75 @@ class VoiceChannel { this.scriptProcessor.connect(silence); silence.connect(this.audioContext.destination); - this.voxActive = false; - this.currentVolume = 0; - const buffer = new Float32Array(this.analyser.fftSize); - this.scriptProcessor.onaudioprocess = () => { - // Use Time Domain Data (Waveform) for better volume measurement (RMS) this.analyser.getFloatTimeDomainData(buffer); - let sum = 0; - for (let i = 0; i < buffer.length; i++) { - sum += buffer[i] * buffer[i]; - } + for (let i = 0; i < buffer.length; i++) sum += buffer[i] * buffer[i]; const rms = Math.sqrt(sum / buffer.length); - - // Scale RMS to 0-1. Speech peak is usually around 0.1-0.3 RMS. - // We'll normalize it so the slider at 0.1 feels natural. this.currentVolume = Math.min(1.0, rms * 3); - if (this.settings.mode !== 'vox') { - this.voxActive = false; - return; - } - + if (this.settings.mode !== 'vox') { this.voxActive = false; return; } if (this.currentVolume > this.settings.voxThreshold) { this.lastVoiceTime = Date.now(); - if (!this.voxActive) { - this.voxActive = true; - this.updateMuteState(); - } + if (!this.voxActive) { this.voxActive = true; this.updateMuteState(); } } else { if (this.voxActive && Date.now() - this.lastVoiceTime > this.voxHoldTime) { - this.voxActive = false; - this.updateMuteState(); + this.voxActive = false; this.updateMuteState(); } } }; - } catch (e) { - console.error('Failed to setup VOX:', e); - } + } catch (e) { console.error('Failed setupVOX:', e); } } - getVolume() { - return this.currentVolume || 0; - } + getVolume() { return this.currentVolume || 0; } updateMuteState() { if (!this.localStream) return; - let shouldTalk = (this.settings.mode === 'ptt') ? this.pttPressed : this.voxActive; if (this.canSpeak === false) shouldTalk = false; if (this.isWhispering) shouldTalk = true; - if (this.isTalking !== shouldTalk || this.lastWhisperState !== this.isWhispering) { - this.isTalking = shouldTalk; + // Transmission is only possible if NOT self-muted + const shouldTransmit = !this.isSelfMuted && shouldTalk; + + if (this.isTalking !== shouldTransmit || this.lastWhisperState !== this.isWhispering) { + this.isTalking = shouldTransmit; this.lastWhisperState = this.isWhispering; - this.applyAudioState(); - this.updateSpeakingUI(window.currentUserId, shouldTalk, this.isWhispering); - - const msg = { - type: 'voice_speaking', - channel_id: this.currentChannelId, - user_id: window.currentUserId, - speaking: shouldTalk, - is_whisper: this.isWhispering - }; - + + // 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 => { if (this.isWhispering) { - if (this.whisperPeers.has(pid)) { - this.sendSignal(pid, msg); - } else { - this.sendSignal(pid, { ...msg, speaking: false }); - } - } else { - this.sendSignal(pid, msg); - } + if (this.whisperPeers.has(pid)) this.sendSignal(pid, msg); + else this.sendSignal(pid, { ...msg, speaking: false }); + } else this.sendSignal(pid, msg); }); - - if (this.isWhispering) { - this.whisperPeers.forEach(pid => { - if (this.peers[pid]) this.sendSignal(pid, msg); - }); - } } } applyAudioState() { - if (this.localStream) { - const shouldTransmit = !this.isSelfMuted && this.isTalking && (this.canSpeak || this.isWhispering); - this.localStream.getAudioTracks().forEach(track => { - track.enabled = shouldTransmit; - }); - + 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); + } + + if (streamToUse) { + 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 (this.isWhispering) { - sender.track.enabled = this.whisperPeers.has(pid); + sender.track.enabled = shouldTransmit && this.whisperPeers.has(pid); } else { - sender.track.enabled = !!this.participants[pid]; + // FIX: Ensure track is only enabled if shouldTransmit is true + sender.track.enabled = shouldTransmit && !!this.participants[pid]; } } }); @@ -619,37 +507,33 @@ class VoiceChannel { this.updateUserPanelButtons(); } - setMute(mute) { - this.isSelfMuted = mute; - this.applyAudioState(); + setMute(mute) { + this.isSelfMuted = mute; + this.updateMuteState(); + this.applyAudioState(); // Always update UI even if not "talking" } - - toggleMute() { - if (this.canSpeak === false) return; - this.setMute(!this.isSelfMuted); - } - + toggleMute() { if (this.canSpeak !== false) this.setMute(!this.isSelfMuted); } toggleDeafen() { this.isDeafened = !this.isDeafened; Object.values(this.remoteAudios).forEach(audio => { audio.muted = this.isDeafened; if (!this.isDeafened) audio.volume = this.settings.outputVolume || 1.0; }); - if (this.isDeafened && !this.isSelfMuted) { - this.setMute(true); + if (this.isDeafened && !this.isSelfMuted) this.setMute(true); + else { + this.applyAudioState(); } - this.applyAudioState(); } setOutputVolume(vol) { this.settings.outputVolume = parseFloat(vol); - Object.values(this.remoteAudios).forEach(audio => { - audio.volume = this.settings.outputVolume; - }); + Object.values(this.remoteAudios).forEach(audio => { audio.volume = this.settings.outputVolume; }); } - - setInputVolume(vol) { - this.settings.inputVolume = parseFloat(vol); + setInputVolume(vol) { + this.settings.inputVolume = parseFloat(vol); + if (this.inputGainNode && this.audioContext && !this.isSelfMuted) { + this.inputGainNode.gain.setTargetAtTime(this.settings.inputVolume, this.audioContext.currentTime, 0.01); + } } async setInputDevice(deviceId) { @@ -657,16 +541,9 @@ class VoiceChannel { if (this.currentChannelId && this.localStream) { const constraints = { audio: this.getAudioConstraints(), video: false }; const newStream = await navigator.mediaDevices.getUserMedia(constraints); - const newTrack = newStream.getAudioTracks()[0]; - - Object.values(this.peers).forEach(pc => { - const sender = pc.getSenders().find(s => s.track && s.track.kind === 'audio'); - if (sender) sender.replaceTrack(newTrack); - }); - this.localStream.getTracks().forEach(t => t.stop()); this.localStream = newStream; - this.setupVOX(); + await this.setupVOX(); this.applyAudioState(); } } @@ -674,9 +551,7 @@ class VoiceChannel { async setOutputDevice(deviceId) { this.settings.outputDevice = deviceId; Object.values(this.remoteAudios).forEach(audio => { - if (typeof audio.setSinkId === 'function') { - audio.setSinkId(deviceId).catch(e => console.error('setSinkId failed:', e)); - } + if (typeof audio.setSinkId === 'function') audio.setSinkId(deviceId).catch(e => console.error(e)); }); } @@ -684,31 +559,20 @@ 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); - const newTrack = newStream.getAudioTracks()[0]; - - Object.values(this.peers).forEach(pc => { - const sender = pc.getSenders().find(s => s.track && s.track.kind === 'audio'); - if (sender) sender.replaceTrack(newTrack); - }); - this.localStream.getTracks().forEach(t => t.stop()); this.localStream = newStream; - this.setupVOX(); + await this.setupVOX(); this.applyAudioState(); - } catch (e) { - console.error('Failed to update audio constraints:', e); - } + } catch (e) { console.error(e); } } } updateUserPanelButtons() { const btnMute = document.getElementById('btn-panel-mute'); const btnDeafen = document.getElementById('btn-panel-deafen'); - - let displayMuted = this.isSelfMuted; - if (this.canSpeak === false) displayMuted = true; - + let displayMuted = this.isSelfMuted || this.canSpeak === false; if (btnMute) { btnMute.classList.toggle('active', displayMuted); btnMute.style.color = displayMuted ? '#f23f43' : 'var(--text-muted)'; @@ -716,7 +580,6 @@ class VoiceChannel { '' : ''; } - if (btnDeafen) { btnDeafen.classList.toggle('active', this.isDeafened); btnDeafen.style.color = this.isDeafened ? '#f23f43' : 'var(--text-muted)'; @@ -730,47 +593,20 @@ class VoiceChannel { if (!this.currentChannelId) return; const cid = this.currentChannelId; const pid = this.myPeerId; - sessionStorage.removeItem('activeVoiceChannel'); sessionStorage.removeItem('activeVoicePeerId'); if (this.pollInterval) clearInterval(this.pollInterval); - fetch(`api_v1_voice.php?action=leave&room=${cid}&peer_id=${pid}`, { keepalive: true }); - - if (this.localStream) { - this.localStream.getTracks().forEach(track => track.stop()); - this.localStream = null; - } - if (this.analysisStream) { - this.analysisStream.getTracks().forEach(track => track.stop()); - this.analysisStream = null; - } - - if (this.scriptProcessor) { - try { - this.scriptProcessor.disconnect(); - this.scriptProcessor.onaudioprocess = null; - } catch(e) {} - this.scriptProcessor = null; - } - if (this.microphone) { - try { this.microphone.disconnect(); } catch(e) {} - this.microphone = null; - } - + if (this.localStream) this.localStream.getTracks().forEach(track => track.stop()); + if (this.processedStream) this.processedStream.getTracks().forEach(track => track.stop()); + if (this.analysisStream) this.analysisStream.getTracks().forEach(track => track.stop()); + if (this.scriptProcessor) { try { this.scriptProcessor.disconnect(); this.scriptProcessor.onaudioprocess = null; } catch(e) {} } + 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) {} } 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.participants = {}; - this.currentChannelId = null; - this.myPeerId = null; - this.speakingUsers.clear(); - + Object.values(this.remoteAudios).forEach(audio => { audio.pause(); audio.remove(); audio.srcObject = null; }); + this.peers = {}; this.remoteAudios = {}; this.participants = {}; this.currentChannelId = null; this.myPeerId = null; this.speakingUsers.clear(); document.querySelectorAll('.voice-item').forEach(el => el.classList.remove('active')); this.updateVoiceUI(); } @@ -791,8 +627,7 @@ class VoiceChannel { - - `; + `; const sidebar = document.querySelector('.channels-sidebar'); if (sidebar) sidebar.appendChild(controls); const btnLeave = document.getElementById('btn-voice-leave'); @@ -806,22 +641,11 @@ class VoiceChannel { updateSpeakingUI(userId, isSpeaking, isWhisper = false) { userId = String(userId); - if (isSpeaking) { - this.speakingUsers.add(userId); - } else { - this.speakingUsers.delete(userId); - } - + if (isSpeaking) this.speakingUsers.add(userId); else this.speakingUsers.delete(userId); const userEls = document.querySelectorAll(`.voice-user[data-user-id="${userId}"]`); userEls.forEach(el => { const avatar = el.querySelector('.message-avatar'); - if (avatar) { - if (isSpeaking) { - avatar.style.boxShadow = isWhisper ? '0 0 0 2px #00a8fc' : '0 0 0 2px #23a559'; - } else { - avatar.style.boxShadow = 'none'; - } - } + if (avatar) avatar.style.boxShadow = isSpeaking ? (isWhisper ? '0 0 0 2px #00a8fc' : '0 0 0 2px #23a559') : 'none'; if (isWhisper && isSpeaking && userId !== String(window.currentUserId)) { if (!el.querySelector('.whisper-label')) { const label = document.createElement('span'); @@ -830,10 +654,7 @@ class VoiceChannel { label.innerText = 'WHISPER'; el.querySelector('span.text-truncate').after(label); } - } else { - const label = el.querySelector('.whisper-label'); - if (label) label.remove(); - } + } else { const label = el.querySelector('.whisper-label'); if (label) label.remove(); } }); } @@ -844,13 +665,10 @@ class VoiceChannel { if (data.success) { document.querySelectorAll('.voice-users-list').forEach(el => el.innerHTML = ''); document.querySelectorAll('.voice-item').forEach(el => el.classList.remove('connected')); - Object.keys(data.channels).forEach(channelId => { const voiceItem = document.querySelector(`.voice-item[data-channel-id="${channelId}"]`); if (voiceItem) { - if (window.voiceHandler && window.voiceHandler.currentChannelId == channelId) { - voiceItem.classList.add('connected'); - } + if (window.voiceHandler && window.voiceHandler.currentChannelId == channelId) voiceItem.classList.add('connected'); const container = voiceItem.closest('.channel-item-container'); if (container) { const listEl = container.querySelector('.voice-users-list'); @@ -865,9 +683,7 @@ class VoiceChannel { } }); } - } catch (e) { - console.error('Failed to refresh voice users:', e); - } + } catch (e) { console.error('Failed refresh voice users:', e); } } static renderUserToUI(container, userId, username, avatarUrl, isSpeaking = false, isMuted = false, isDeafened = false) { @@ -877,16 +693,13 @@ class VoiceChannel { userEl.style.paddingLeft = '8px'; const avatarStyle = avatarUrl ? `background-image: url('${avatarUrl}'); background-size: cover;` : "background-color: #555;"; const boxShadow = isSpeaking ? 'box-shadow: 0 0 0 2px #23a559;' : ''; - let icons = ''; if (isDeafened) icons += ''; else if (isMuted) icons += ''; - userEl.innerHTML = `
${username} - ${icons} - `; + ${icons}`; container.appendChild(userEl); } } \ No newline at end of file diff --git a/index.php b/index.php index a40ec2e..2cd0164 100644 --- a/index.php +++ b/index.php @@ -1880,8 +1880,9 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
- - + + +
0%100%400%