Autosave: 20260330-071859
This commit is contained in:
parent
af65d18996
commit
d299b9c342
@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
$content = file_get_contents('index.php');
|
|
||||||
$open = substr_count($content, '<div');
|
|
||||||
$close = substr_count($content, '</div');
|
|
||||||
echo "Open: $open, Close: $close\n";
|
|
||||||
|
|
||||||
// Trace where it goes wrong
|
|
||||||
$lines = explode("\n", $content);
|
|
||||||
$depth = 0;
|
|
||||||
foreach ($lines as $i => $line) {
|
|
||||||
$line_open = substr_count($line, '<div');
|
|
||||||
$line_close = substr_count($line, '</div');
|
|
||||||
$old_depth = $depth;
|
|
||||||
$depth += $line_open - $line_close;
|
|
||||||
if (($i + 1) >= 760 && ($i + 1) <= 780) {
|
|
||||||
echo "Line " . ($i + 1) . ": Depth $old_depth -> $depth | " . trim($line) . "\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
echo "Final depth: $depth\n";
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
<?php
|
|
||||||
require_once __DIR__ . '/db/config.php';
|
|
||||||
try {
|
|
||||||
$stmt = db()->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__);
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
require_once __DIR__ . '/db/config.php';
|
|
||||||
|
|
||||||
$username = 'admin';
|
|
||||||
$email = 'admin@corvara.com';
|
|
||||||
$password = 'admin123';
|
|
||||||
$password_hash = password_hash($password, PASSWORD_DEFAULT);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$pdo = db();
|
|
||||||
|
|
||||||
// Vérifier si l'utilisateur existe déjà
|
|
||||||
$stmt = $pdo->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 "<h1>Succès !</h1>";
|
|
||||||
echo "<p>Compte administrateur créé avec succès.</p>";
|
|
||||||
echo "<ul>";
|
|
||||||
echo "<li><b>Email :</b> $email</li>";
|
|
||||||
echo "<li><b>Mot de passe :</b> $password</li>";
|
|
||||||
echo "</ul>";
|
|
||||||
echo "<p style='color:red;'><b>IMPORTANT : Supprimez ce fichier (create_admin.php) immédiatement après utilisation pour des raisons de sécurité.</b></p>";
|
|
||||||
}
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
echo "Erreur lors de la création du compte : " . $e->getMessage();
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
<?php
|
|
||||||
require_once 'db/config.php';
|
|
||||||
|
|
||||||
try {
|
|
||||||
$sql = file_get_contents('database/schema.sql');
|
|
||||||
db()->exec($sql);
|
|
||||||
echo "Database initialized successfully.\n";
|
|
||||||
} catch (Exception $e) {
|
|
||||||
echo "Error initializing database: " . $e->getMessage() . "\n";
|
|
||||||
}
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
<?php
|
|
||||||
require_once __DIR__ . '/db/config.php';
|
|
||||||
|
|
||||||
header('Content-Type: text/html; charset=utf-8');
|
|
||||||
|
|
||||||
echo "<h1>Diagnostic du Projet Corvara</h1>";
|
|
||||||
|
|
||||||
// 1. Test de la connexion
|
|
||||||
try {
|
|
||||||
$pdo = db();
|
|
||||||
echo "<p style='color:green;'>✅ Connexion à la base de données réussie.</p>";
|
|
||||||
} catch (Exception $e) {
|
|
||||||
echo "<p style='color:red;'>❌ Erreur de connexion : " . htmlspecialchars($e->getMessage()) . "</p>";
|
|
||||||
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 "<h2>Vérification des tables :</h2><ul>";
|
|
||||||
foreach ($required_tables as $table) {
|
|
||||||
try {
|
|
||||||
$pdo->query("SELECT 1 FROM `$table` LIMIT 1");
|
|
||||||
echo "<li>$table : <span style='color:green;'>OK</span></li>";
|
|
||||||
} catch (Exception $e) {
|
|
||||||
echo "<li>$table : <span style='color:red;'>MANQUANTE</span> (C'est probablement la cause de l'erreur 500)</li>";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
echo "</ul>";
|
|
||||||
|
|
||||||
// 3. Extensions PHP
|
|
||||||
echo "<h2>Extensions PHP :</h2><ul>";
|
|
||||||
$exts = ['pdo_mysql', 'mbstring', 'curl', 'gd'];
|
|
||||||
foreach ($exts as $ext) {
|
|
||||||
if (extension_loaded($ext)) {
|
|
||||||
echo "<li>$ext : <span style='color:green;'>Installée</span></li>";
|
|
||||||
} else {
|
|
||||||
echo "<li>$ext : <span style='color:red;'>Absente</span></li>";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
echo "</ul>";
|
|
||||||
|
|
||||||
// 4. Vérification de l'administrateur
|
|
||||||
echo "<h2>Compte Administrateur :</h2>";
|
|
||||||
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 "<p style='color:green;'>✅ SuperAdmin (admin@corvara.com) présent.</p>";
|
|
||||||
} else {
|
|
||||||
echo "<p style='color:orange;'>⚠️ SuperAdmin absent. Utilisez create_admin.php pour le créer.</p>";
|
|
||||||
}
|
|
||||||
} catch (Exception $e) {
|
|
||||||
echo "<p style='color:red;'>Impossible de vérifier l'admin (table users manquante ?).</p>";
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "<hr><p>Si des tables sont manquantes, exécutez <b>fix_db.php</b>.</p>";
|
|
||||||
103
Temp/fix_db.php
103
Temp/fix_db.php
@ -1,103 +0,0 @@
|
|||||||
<?php
|
|
||||||
require_once __DIR__ . '/db/config.php';
|
|
||||||
|
|
||||||
header('Content-Type: text/html; charset=utf-8');
|
|
||||||
|
|
||||||
echo "<h1>Réparation de la base de données</h1>";
|
|
||||||
|
|
||||||
try {
|
|
||||||
$pdo = db();
|
|
||||||
} catch (Exception $e) {
|
|
||||||
die("<p style='color:red;'>Erreur de connexion : " . $e->getMessage() . "</p>");
|
|
||||||
}
|
|
||||||
|
|
||||||
$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 "<p>✅ Exécution réussie d'une requête de création.</p>";
|
|
||||||
} catch (Exception $e) {
|
|
||||||
echo "<p style='color:orange;'>⚠️ Info : " . $e->getMessage() . " (Peut-être que la table existe déjà)</p>";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "<h2>Vérification finale des colonnes (Correction Erreur 500 courante)</h2>";
|
|
||||||
// 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 "<p>✅ Mise à jour des colonnes 'channels' terminée.</p>";
|
|
||||||
} catch (Exception $e) {
|
|
||||||
echo "<p>ℹ️ Colonnes déjà présentes dans 'channels'.</p>";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 "<p style='color:green;'>✅ Compte SuperAdmin créé (admin@corvara.com / admin123).</p>";
|
|
||||||
}
|
|
||||||
} catch (Exception $e) {
|
|
||||||
echo "<p style='color:orange;'>⚠️ Impossible de créer l'admin automatique : " . $e->getMessage() . "</p>";
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "<hr><p style='color:green;'>Terminé ! Essayez de rafraîchir votre page index.php.</p>";
|
|
||||||
echo "<p style='color:red;'><b>IMPORTANT : Supprimez ce fichier (fix_db.php) après usage.</b></p>";
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
<?php
|
|
||||||
error_reporting(E_ALL);
|
|
||||||
ini_set('display_errors', 1);
|
|
||||||
$_SESSION['user_id'] = 2; // Simulate logged in user
|
|
||||||
$_SERVER['REQUEST_METHOD'] = 'POST';
|
|
||||||
$_POST = [
|
|
||||||
'channel_id' => 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;
|
|
||||||
115
assets/js/rnnoise-processor.js
Normal file
115
assets/js/rnnoise-processor.js
Normal file
@ -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);
|
||||||
BIN
assets/js/rnnoise.wasm
Normal file
BIN
assets/js/rnnoise.wasm
Normal file
Binary file not shown.
@ -9,14 +9,15 @@ class VoiceChannel {
|
|||||||
voxThreshold: 0.1,
|
voxThreshold: 0.1,
|
||||||
inputDevice: 'default',
|
inputDevice: 'default',
|
||||||
outputDevice: 'default',
|
outputDevice: 'default',
|
||||||
inputVolume: 1.0,
|
inputVolume: parseFloat(localStorage.getItem('voice_input_volume') || 1.0),
|
||||||
outputVolume: 1.0,
|
outputVolume: parseFloat(localStorage.getItem('voice_output_volume') || 1.0),
|
||||||
echoCancellation: true,
|
echoCancellation: true,
|
||||||
noiseSuppression: true,
|
noiseSuppression: true,
|
||||||
advancedFilters: true
|
advancedFilters: true
|
||||||
};
|
};
|
||||||
console.log('VoiceChannel constructor called with settings:', this.settings);
|
console.log('VoiceChannel constructor called with settings:', this.settings);
|
||||||
this.localStream = null;
|
this.localStream = null;
|
||||||
|
this.processedStream = null;
|
||||||
this.analysisStream = null;
|
this.analysisStream = null;
|
||||||
this.peers = {}; // userId -> RTCPeerConnection
|
this.peers = {}; // userId -> RTCPeerConnection
|
||||||
this.participants = {}; // userId -> {name}
|
this.participants = {}; // userId -> {name}
|
||||||
@ -38,78 +39,69 @@ class VoiceChannel {
|
|||||||
this.audioContext = null;
|
this.audioContext = null;
|
||||||
this.analyser = null;
|
this.analyser = null;
|
||||||
this.microphone = null;
|
this.microphone = null;
|
||||||
|
this.inputGainNode = null;
|
||||||
this.scriptProcessor = null;
|
this.scriptProcessor = null;
|
||||||
this.inputGain = null;
|
this.rnnoiseNode = null;
|
||||||
|
this.rnnoiseLoaded = false;
|
||||||
|
this.rnnoiseWasmBinary = null;
|
||||||
|
|
||||||
this.isTalking = false;
|
this.isTalking = false;
|
||||||
this.pttPressed = false;
|
this.pttPressed = false;
|
||||||
this.voxActive = false;
|
this.voxActive = false;
|
||||||
this.lastVoiceTime = 0;
|
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.speakingUsers = new Set();
|
||||||
|
|
||||||
this.setupPTTListeners();
|
this.setupPTTListeners();
|
||||||
this.loadWhisperSettings();
|
this.loadWhisperSettings();
|
||||||
|
|
||||||
// Auto-rejoin if we were in a channel
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const savedChannelId = sessionStorage.getItem('activeVoiceChannel');
|
const savedChannelId = sessionStorage.getItem('activeVoiceChannel');
|
||||||
const savedPeerId = sessionStorage.getItem('activeVoicePeerId');
|
const savedPeerId = sessionStorage.getItem('activeVoicePeerId');
|
||||||
if (savedChannelId) {
|
if (savedChannelId) {
|
||||||
console.log('Auto-rejoining voice channel:', savedChannelId);
|
console.log('Auto-rejoining voice channel:', savedChannelId);
|
||||||
if (savedPeerId) this.myPeerId = savedPeerId;
|
if (savedPeerId) this.myPeerId = savedPeerId;
|
||||||
this.join(savedChannelId, true); // Pass true to indicate auto-rejoin
|
this.join(savedChannelId, true);
|
||||||
}
|
}
|
||||||
}, 200);
|
}, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alias for index.php compatibility
|
|
||||||
set whisperParamètres(val) {
|
set whisperParamètres(val) {
|
||||||
this.whisperSettings = val;
|
this.whisperSettings = val;
|
||||||
}
|
}
|
||||||
|
|
||||||
getAudioConstraints() {
|
getAudioConstraints() {
|
||||||
const useAdvanced = this.settings.advancedFilters !== false;
|
const useAdvanced = this.settings.advancedFilters !== false;
|
||||||
|
const ns = !!this.settings.noiseSuppression;
|
||||||
|
const ec = !!this.settings.echoCancellation;
|
||||||
|
|
||||||
const constraints = {
|
return {
|
||||||
echoCancellation: { ideal: this.settings.echoCancellation },
|
echoCancellation: ec,
|
||||||
noiseSuppression: { ideal: this.settings.noiseSuppression },
|
noiseSuppression: ns,
|
||||||
autoGainControl: { ideal: useAdvanced },
|
autoGainControl: useAdvanced,
|
||||||
// Chromium-specific flags
|
googEchoCancellation: ec,
|
||||||
googEchoCancellation: { ideal: this.settings.echoCancellation },
|
googAutoGainControl: useAdvanced,
|
||||||
googAutoGainControl: { ideal: useAdvanced },
|
googNoiseSuppression: ns,
|
||||||
googNoiseSuppression: { ideal: this.settings.noiseSuppression },
|
googHighpassFilter: useAdvanced,
|
||||||
googHighpassFilter: { ideal: useAdvanced },
|
googTypingNoiseDetection: true,
|
||||||
googTypingNoiseDetection: { ideal: true },
|
googAudioMirroring: false,
|
||||||
googAudioMirroring: { ideal: false },
|
googNoiseReduction: ns,
|
||||||
googNoiseReduction: { ideal: this.settings.noiseSuppression },
|
googAutoGainControl2: useAdvanced,
|
||||||
googAutoGainControl2: { ideal: useAdvanced },
|
channelCount: 1,
|
||||||
// Standard constraints
|
sampleRate: 48000,
|
||||||
channelCount: { ideal: 1 },
|
sampleSize: 16,
|
||||||
sampleRate: { ideal: 48000 },
|
latency: 0.005
|
||||||
sampleSize: { ideal: 16 }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (this.settings.inputDevice && this.settings.inputDevice !== 'default') {
|
|
||||||
constraints.deviceId = { ideal: this.settings.inputDevice };
|
|
||||||
}
|
|
||||||
|
|
||||||
return constraints;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setupPTTListeners() {
|
setupPTTListeners() {
|
||||||
window.addEventListener('keydown', (e) => {
|
window.addEventListener('keydown', (e) => {
|
||||||
// Ignore if in input field
|
|
||||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||||
|
|
||||||
// Normal PTT
|
|
||||||
if (this.settings.mode === 'ptt') {
|
if (this.settings.mode === 'ptt') {
|
||||||
const isMatch = e.key.toLowerCase() === this.settings.pttKey.toLowerCase() ||
|
const isMatch = e.key.toLowerCase() === this.settings.pttKey.toLowerCase() ||
|
||||||
(e.code && e.code.toLowerCase() === this.settings.pttKey.toLowerCase()) ||
|
(e.code && e.code.toLowerCase() === this.settings.pttKey.toLowerCase()) ||
|
||||||
(this.settings.pttKey === '0' && e.code === 'Numpad0');
|
(this.settings.pttKey === '0' && e.code === 'Numpad0');
|
||||||
|
|
||||||
if (isMatch) {
|
if (isMatch) {
|
||||||
if (!this.pttPressed) {
|
if (!this.pttPressed) {
|
||||||
this.pttPressed = true;
|
this.pttPressed = true;
|
||||||
@ -118,29 +110,23 @@ class VoiceChannel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whispers
|
|
||||||
this.whisperSettings.forEach(w => {
|
this.whisperSettings.forEach(w => {
|
||||||
if (e.key.toLowerCase() === w.whisper_key.toLowerCase()) {
|
if (e.key.toLowerCase() === w.whisper_key.toLowerCase()) {
|
||||||
this.startWhisper(w);
|
this.startWhisper(w);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('keyup', (e) => {
|
window.addEventListener('keyup', (e) => {
|
||||||
if (this.settings.mode === 'ptt') {
|
if (this.settings.mode === 'ptt') {
|
||||||
const isMatch = e.key.toLowerCase() === this.settings.pttKey.toLowerCase() ||
|
const isMatch = e.key.toLowerCase() === this.settings.pttKey.toLowerCase() ||
|
||||||
(e.code && e.code.toLowerCase() === this.settings.pttKey.toLowerCase()) ||
|
(e.code && e.code.toLowerCase() === this.settings.pttKey.toLowerCase()) ||
|
||||||
(this.settings.pttKey === '0' && e.code === 'Numpad0');
|
(this.settings.pttKey === '0' && e.code === 'Numpad0');
|
||||||
|
|
||||||
if (isMatch) {
|
if (isMatch) {
|
||||||
this.pttPressed = false;
|
this.pttPressed = false;
|
||||||
this.updateMuteState();
|
this.updateMuteState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whispers
|
|
||||||
this.whisperSettings.forEach(w => {
|
this.whisperSettings.forEach(w => {
|
||||||
if (e.key.toLowerCase() === w.whisper_key.toLowerCase()) {
|
if (e.key.toLowerCase() === w.whisper_key.toLowerCase()) {
|
||||||
this.stopWhisper(w);
|
this.stopWhisper(w);
|
||||||
@ -155,44 +141,32 @@ class VoiceChannel {
|
|||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
this.whisperSettings = data.whispers;
|
this.whisperSettings = data.whispers;
|
||||||
console.log('VoiceChannel: Loaded whispers:', this.whisperSettings);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load whispers in VoiceChannel:', e);
|
console.error('Failed to load whispers:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupWhisperListeners() {
|
setupWhisperListeners() {
|
||||||
// This is called when settings are updated in the UI
|
|
||||||
this.loadWhisperSettings();
|
this.loadWhisperSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
async startWhisper(config) {
|
async startWhisper(config) {
|
||||||
if (this.isWhispering) return;
|
if (this.isWhispering) return;
|
||||||
console.log('Starting whisper to:', config.target_type, config.target_id);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`api_v1_voice.php?action=find_whisper_targets&target_type=${config.target_type}&target_id=${config.target_id}`);
|
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();
|
const data = await resp.json();
|
||||||
|
|
||||||
if (data.success && data.targets.length > 0) {
|
if (data.success && data.targets.length > 0) {
|
||||||
this.isWhispering = true;
|
this.isWhispering = true;
|
||||||
this.whisperPeers.clear();
|
this.whisperPeers.clear();
|
||||||
|
|
||||||
for (const target of data.targets) {
|
for (const target of data.targets) {
|
||||||
if (target.peer_id === this.myPeerId) continue;
|
if (target.peer_id === this.myPeerId) continue;
|
||||||
this.whisperPeers.add(target.peer_id);
|
this.whisperPeers.add(target.peer_id);
|
||||||
|
|
||||||
// Establish connection if not exists
|
|
||||||
if (!this.peers[target.peer_id]) {
|
if (!this.peers[target.peer_id]) {
|
||||||
console.log('Establishing temporary connection for whisper to:', target.peer_id);
|
|
||||||
this.createPeerConnection(target.peer_id, true);
|
this.createPeerConnection(target.peer_id, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateMuteState();
|
this.updateMuteState();
|
||||||
} else {
|
|
||||||
console.log('No active targets found for whisper.');
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Whisper start error:', e);
|
console.error('Whisper start error:', e);
|
||||||
@ -201,68 +175,45 @@ class VoiceChannel {
|
|||||||
|
|
||||||
stopWhisper(config) {
|
stopWhisper(config) {
|
||||||
if (!this.isWhispering) return;
|
if (!this.isWhispering) return;
|
||||||
console.log('Stopping whisper');
|
|
||||||
this.isWhispering = false;
|
this.isWhispering = false;
|
||||||
this.whisperPeers.clear();
|
this.whisperPeers.clear();
|
||||||
this.updateMuteState();
|
this.updateMuteState();
|
||||||
}
|
}
|
||||||
|
|
||||||
async join(channelId, isAutoRejoin = false) {
|
async join(channelId, isAutoRejoin = false) {
|
||||||
console.log('VoiceChannel.join process started for channel:', channelId, 'isAutoRejoin:', isAutoRejoin);
|
if (this.currentChannelId === channelId && !isAutoRejoin) return;
|
||||||
if (this.currentChannelId === channelId && !isAutoRejoin) {
|
if (this.currentChannelId && this.currentChannelId != channelId) this.leave();
|
||||||
console.log('Already in this channel');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.currentChannelId && this.currentChannelId != channelId) {
|
|
||||||
console.log('Leaving previous channel:', this.currentChannelId);
|
|
||||||
this.leave();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentChannelId = channelId;
|
this.currentChannelId = channelId;
|
||||||
sessionStorage.setItem('activeVoiceChannel', channelId);
|
sessionStorage.setItem('activeVoiceChannel', channelId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('Requesting microphone access with device:', this.settings.inputDevice);
|
const constraints = { audio: this.getAudioConstraints(), video: false };
|
||||||
const constraints = {
|
console.log('Requesting mic with constraints:', constraints);
|
||||||
audio: this.getAudioConstraints(),
|
|
||||||
video: false
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.localStream = await navigator.mediaDevices.getUserMedia(constraints);
|
this.localStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
} catch (err) {
|
} 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 });
|
this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Microphone access granted');
|
this.setMute(false);
|
||||||
this.setMute(false); // Join unmuted by default (self-mute off)
|
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 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 resp = await fetch(url);
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
console.log('API join response:', data);
|
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
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);
|
||||||
console.log('Joined room with peer_id:', this.myPeerId);
|
|
||||||
|
|
||||||
// Start polling
|
|
||||||
this.startPolling();
|
this.startPolling();
|
||||||
this.updateVoiceUI();
|
this.updateVoiceUI();
|
||||||
} else {
|
|
||||||
console.error('API join failed:', data.error);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to join voice:', 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;
|
this.currentChannelId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -270,57 +221,40 @@ class VoiceChannel {
|
|||||||
startPolling() {
|
startPolling() {
|
||||||
if (this.pollInterval) clearInterval(this.pollInterval);
|
if (this.pollInterval) clearInterval(this.pollInterval);
|
||||||
this.pollInterval = setInterval(() => this.poll(), 500);
|
this.pollInterval = setInterval(() => this.poll(), 500);
|
||||||
this.poll(); // Initial poll
|
this.poll();
|
||||||
}
|
}
|
||||||
|
|
||||||
async poll() {
|
async poll() {
|
||||||
if (!this.myPeerId || !this.currentChannelId) return;
|
if (!this.myPeerId || !this.currentChannelId) return;
|
||||||
|
|
||||||
try {
|
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 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();
|
const data = await resp.json();
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
this.canSpeak = data.can_speak !== false;
|
this.canSpeak = data.can_speak !== false;
|
||||||
// Update participants
|
|
||||||
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);
|
||||||
|
|
||||||
// If new people joined, initiate offer
|
|
||||||
newPs.forEach(pid => {
|
newPs.forEach(pid => {
|
||||||
if (pid !== this.myPeerId && !this.peers[pid]) {
|
if (pid !== this.myPeerId && !this.peers[pid]) this.createPeerConnection(pid, true);
|
||||||
console.log('New peer found via poll:', pid);
|
|
||||||
this.createPeerConnection(pid, true);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cleanup left peers
|
|
||||||
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) && !this.speakingUsers.has(pid)) {
|
||||||
console.log('Peer left or not in channel anymore:', pid);
|
|
||||||
this.peers[pid].close();
|
this.peers[pid].close();
|
||||||
delete this.peers[pid];
|
delete this.peers[pid];
|
||||||
if (this.remoteAudios[pid]) {
|
if (this.remoteAudios[pid]) {
|
||||||
this.remoteAudios[pid].pause();
|
this.remoteAudios[pid].pause();
|
||||||
|
this.remoteAudios[pid].srcObject = null;
|
||||||
this.remoteAudios[pid].remove();
|
this.remoteAudios[pid].remove();
|
||||||
delete this.remoteAudios[pid];
|
delete this.remoteAudios[pid];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle incoming signals
|
|
||||||
if (data.signals && data.signals.length > 0) {
|
if (data.signals && data.signals.length > 0) {
|
||||||
for (const sig of data.signals) {
|
for (const sig of data.signals) await this.handleSignaling(sig);
|
||||||
await this.handleSignaling(sig);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateVoiceUI();
|
this.updateVoiceUI();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) { console.error('Polling error:', e); }
|
||||||
console.error('Polling error:', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendSignal(to, data) {
|
async sendSignal(to, data) {
|
||||||
@ -330,12 +264,7 @@ class VoiceChannel {
|
|||||||
|
|
||||||
createPeerConnection(userId, isOfferor) {
|
createPeerConnection(userId, isOfferor) {
|
||||||
if (this.peers[userId]) return this.peers[userId];
|
if (this.peers[userId]) return this.peers[userId];
|
||||||
|
if (!this.peerStates[userId]) this.peerStates[userId] = { makingOffer: false, ignoreOffer: false };
|
||||||
console.log('Creating PeerConnection for:', userId, 'as offeror:', isOfferor);
|
|
||||||
|
|
||||||
if (!this.peerStates[userId]) {
|
|
||||||
this.peerStates[userId] = { makingOffer: false, ignoreOffer: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const pc = new RTCPeerConnection({
|
const pc = new RTCPeerConnection({
|
||||||
iceServers: [
|
iceServers: [
|
||||||
@ -344,51 +273,34 @@ class VoiceChannel {
|
|||||||
{ urls: 'stun:stun2.l.google.com:19302' }
|
{ urls: 'stun:stun2.l.google.com:19302' }
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
this.peers[userId] = pc;
|
this.peers[userId] = pc;
|
||||||
|
|
||||||
pc.oniceconnectionstatechange = () => {
|
|
||||||
console.log(`ICE Connection State with ${userId}: ${pc.iceConnectionState}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
pc.onnegotiationneeded = async () => {
|
pc.onnegotiationneeded = async () => {
|
||||||
try {
|
try {
|
||||||
this.peerStates[userId].makingOffer = true;
|
this.peerStates[userId].makingOffer = true;
|
||||||
await pc.setLocalDescription();
|
await pc.setLocalDescription();
|
||||||
this.sendSignal(userId, { type: 'offer', offer: pc.localDescription });
|
this.sendSignal(userId, { type: 'offer', offer: pc.localDescription });
|
||||||
} catch (err) {
|
} catch (err) { console.error('onnegotiationneeded error:', err); }
|
||||||
console.error('onnegotiationneeded error:', err);
|
finally { this.peerStates[userId].makingOffer = false; }
|
||||||
} finally {
|
|
||||||
this.peerStates[userId].makingOffer = false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (this.localStream) {
|
const streamToUse = this.processedStream || this.localStream;
|
||||||
this.localStream.getTracks().forEach(track => {
|
if (streamToUse) {
|
||||||
pc.addTrack(track, this.localStream);
|
streamToUse.getTracks().forEach(track => pc.addTrack(track, streamToUse));
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pc.onicecandidate = (event) => {
|
pc.onicecandidate = (event) => {
|
||||||
if (event.candidate) {
|
if (event.candidate) this.sendSignal(userId, { type: 'ice_candidate', candidate: event.candidate });
|
||||||
this.sendSignal(userId, { type: 'ice_candidate', candidate: event.candidate });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pc.ontrack = (event) => {
|
pc.ontrack = (event) => {
|
||||||
console.log('Received remote track from:', userId);
|
|
||||||
const stream = event.streams[0] || new MediaStream([event.track]);
|
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]) {
|
if (this.remoteAudios[userId]) {
|
||||||
this.remoteAudios[userId].pause();
|
this.remoteAudios[userId].pause();
|
||||||
this.remoteAudios[userId].srcObject = null;
|
this.remoteAudios[userId].srcObject = null;
|
||||||
this.remoteAudios[userId].remove();
|
this.remoteAudios[userId].remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
const remoteAudio = new Audio();
|
const remoteAudio = new Audio();
|
||||||
remoteAudio.autoplay = true;
|
remoteAudio.autoplay = true;
|
||||||
remoteAudio.style.display = 'none';
|
remoteAudio.style.display = 'none';
|
||||||
@ -400,41 +312,24 @@ class VoiceChannel {
|
|||||||
}
|
}
|
||||||
document.body.appendChild(remoteAudio);
|
document.body.appendChild(remoteAudio);
|
||||||
this.remoteAudios[userId] = remoteAudio;
|
this.remoteAudios[userId] = remoteAudio;
|
||||||
|
remoteAudio.play().catch(e => console.warn('Autoplay prevented:', userId, e));
|
||||||
remoteAudio.play().catch(e => {
|
|
||||||
console.warn('Autoplay prevented for:', userId, e);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isOfferor && pc.signalingState === 'stable') {
|
if (isOfferor && pc.signalingState === 'stable') pc.onnegotiationneeded();
|
||||||
pc.onnegotiationneeded();
|
|
||||||
}
|
|
||||||
|
|
||||||
return pc;
|
return pc;
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
case 'offer':
|
case 'offer': await this.handleOffer(from, data.offer); break;
|
||||||
await this.handleOffer(from, data.offer);
|
case 'answer': await this.handleAnswer(from, data.answer); break;
|
||||||
break;
|
case 'ice_candidate': await this.handleCandidate(from, data.candidate); break;
|
||||||
case 'answer':
|
case 'voice_speaking': this.updateSpeakingUI(data.user_id, data.speaking, data.is_whisper); break;
|
||||||
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) {
|
} catch (err) { console.error('Signaling error:', err); }
|
||||||
console.error('Signaling error:', err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleOffer(from, offer) {
|
async handleOffer(from, offer) {
|
||||||
@ -443,9 +338,7 @@ class VoiceChannel {
|
|||||||
const offerCollision = (offer.type === "offer") && (state.makingOffer || pc.signalingState !== "stable");
|
const offerCollision = (offer.type === "offer") && (state.makingOffer || pc.signalingState !== "stable");
|
||||||
const isPolite = this.myPeerId > from;
|
const isPolite = this.myPeerId > from;
|
||||||
state.ignoreOffer = !isPolite && offerCollision;
|
state.ignoreOffer = !isPolite && offerCollision;
|
||||||
|
|
||||||
if (state.ignoreOffer) return;
|
if (state.ignoreOffer) return;
|
||||||
|
|
||||||
await pc.setRemoteDescription(new RTCSessionDescription(offer));
|
await pc.setRemoteDescription(new RTCSessionDescription(offer));
|
||||||
if (offer.type === "offer") {
|
if (offer.type === "offer") {
|
||||||
await pc.setLocalDescription();
|
await pc.setLocalDescription();
|
||||||
@ -455,54 +348,82 @@ class VoiceChannel {
|
|||||||
|
|
||||||
async handleAnswer(from, answer) {
|
async handleAnswer(from, answer) {
|
||||||
const pc = this.peers[from];
|
const pc = this.peers[from];
|
||||||
if (pc) {
|
if (pc) await pc.setRemoteDescription(new RTCSessionDescription(answer));
|
||||||
await pc.setRemoteDescription(new RTCSessionDescription(answer));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleCandidate(from, candidate) {
|
async handleCandidate(from, candidate) {
|
||||||
const pc = this.peers[from];
|
const pc = this.peers[from];
|
||||||
try {
|
try { if (pc) await pc.addIceCandidate(new RTCIceCandidate(candidate)); } catch (err) {}
|
||||||
if (pc) {
|
|
||||||
await pc.addIceCandidate(new RTCIceCandidate(candidate));
|
|
||||||
}
|
|
||||||
} catch (err) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setupVOX() {
|
async setupVOX() {
|
||||||
if (!this.localStream) return;
|
if (!this.localStream) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!this.audioContext) {
|
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') 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.audioContext.state === 'suspended') {
|
// Cleanup old nodes
|
||||||
this.audioContext.resume();
|
[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;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.scriptProcessor) {
|
// 2. Input Gain (Amplification) AFTER Noise Suppression
|
||||||
this.scriptProcessor.onaudioprocess = null;
|
this.inputGainNode = this.audioContext.createGain();
|
||||||
try { this.scriptProcessor.disconnect(); } catch(e) {}
|
this.inputGainNode.gain.value = this.isSelfMuted ? 0 : (this.settings.inputVolume || 1.0);
|
||||||
}
|
lastNode.connect(this.inputGainNode);
|
||||||
if (this.microphone) {
|
lastNode = this.inputGainNode;
|
||||||
try { this.microphone.disconnect(); } catch(e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 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 = this.audioContext.createAnalyser();
|
||||||
this.analyser.fftSize = 1024; // Better resolution
|
this.analyser.fftSize = 1024;
|
||||||
this.analyser.smoothingTimeConstant = 0.3; // Less jitter
|
this.analyser.smoothingTimeConstant = 0.3;
|
||||||
|
|
||||||
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.scriptProcessor = this.audioContext.createScriptProcessor(2048, 1, 1);
|
this.scriptProcessor = this.audioContext.createScriptProcessor(2048, 1, 1);
|
||||||
|
|
||||||
this.microphone.connect(this.analyser);
|
lastNode.connect(this.analyser);
|
||||||
this.analyser.connect(this.scriptProcessor);
|
this.analyser.connect(this.scriptProcessor);
|
||||||
|
|
||||||
const silence = this.audioContext.createGain();
|
const silence = this.audioContext.createGain();
|
||||||
@ -510,108 +431,75 @@ class VoiceChannel {
|
|||||||
this.scriptProcessor.connect(silence);
|
this.scriptProcessor.connect(silence);
|
||||||
silence.connect(this.audioContext.destination);
|
silence.connect(this.audioContext.destination);
|
||||||
|
|
||||||
this.voxActive = false;
|
|
||||||
this.currentVolume = 0;
|
|
||||||
|
|
||||||
const buffer = new Float32Array(this.analyser.fftSize);
|
const buffer = new Float32Array(this.analyser.fftSize);
|
||||||
|
|
||||||
this.scriptProcessor.onaudioprocess = () => {
|
this.scriptProcessor.onaudioprocess = () => {
|
||||||
// Use Time Domain Data (Waveform) for better volume measurement (RMS)
|
|
||||||
this.analyser.getFloatTimeDomainData(buffer);
|
this.analyser.getFloatTimeDomainData(buffer);
|
||||||
|
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
for (let i = 0; i < buffer.length; i++) {
|
for (let i = 0; i < buffer.length; i++) sum += buffer[i] * buffer[i];
|
||||||
sum += buffer[i] * buffer[i];
|
|
||||||
}
|
|
||||||
const rms = Math.sqrt(sum / buffer.length);
|
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);
|
this.currentVolume = Math.min(1.0, rms * 3);
|
||||||
|
|
||||||
if (this.settings.mode !== 'vox') {
|
if (this.settings.mode !== 'vox') { this.voxActive = false; return; }
|
||||||
this.voxActive = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.currentVolume > this.settings.voxThreshold) {
|
if (this.currentVolume > this.settings.voxThreshold) {
|
||||||
this.lastVoiceTime = Date.now();
|
this.lastVoiceTime = Date.now();
|
||||||
if (!this.voxActive) {
|
if (!this.voxActive) { this.voxActive = true; this.updateMuteState(); }
|
||||||
this.voxActive = true;
|
|
||||||
this.updateMuteState();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (this.voxActive && Date.now() - this.lastVoiceTime > this.voxHoldTime) {
|
if (this.voxActive && Date.now() - this.lastVoiceTime > this.voxHoldTime) {
|
||||||
this.voxActive = false;
|
this.voxActive = false; this.updateMuteState();
|
||||||
this.updateMuteState();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) { console.error('Failed setupVOX:', e); }
|
||||||
console.error('Failed to setup VOX:', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getVolume() {
|
getVolume() { return this.currentVolume || 0; }
|
||||||
return this.currentVolume || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateMuteState() {
|
updateMuteState() {
|
||||||
if (!this.localStream) return;
|
if (!this.localStream) return;
|
||||||
|
|
||||||
let shouldTalk = (this.settings.mode === 'ptt') ? this.pttPressed : this.voxActive;
|
let shouldTalk = (this.settings.mode === 'ptt') ? this.pttPressed : this.voxActive;
|
||||||
if (this.canSpeak === false) shouldTalk = false;
|
if (this.canSpeak === false) shouldTalk = false;
|
||||||
if (this.isWhispering) shouldTalk = true;
|
if (this.isWhispering) shouldTalk = true;
|
||||||
|
|
||||||
if (this.isTalking !== shouldTalk || this.lastWhisperState !== this.isWhispering) {
|
// Transmission is only possible if NOT self-muted
|
||||||
this.isTalking = shouldTalk;
|
const shouldTransmit = !this.isSelfMuted && shouldTalk;
|
||||||
|
|
||||||
|
if (this.isTalking !== shouldTransmit || this.lastWhisperState !== this.isWhispering) {
|
||||||
|
this.isTalking = shouldTransmit;
|
||||||
this.lastWhisperState = this.isWhispering;
|
this.lastWhisperState = this.isWhispering;
|
||||||
|
|
||||||
this.applyAudioState();
|
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 => {
|
Object.keys(this.peers).forEach(pid => {
|
||||||
if (this.isWhispering) {
|
if (this.isWhispering) {
|
||||||
if (this.whisperPeers.has(pid)) {
|
if (this.whisperPeers.has(pid)) this.sendSignal(pid, msg);
|
||||||
this.sendSignal(pid, msg);
|
else this.sendSignal(pid, { ...msg, speaking: false });
|
||||||
} else {
|
} else this.sendSignal(pid, msg);
|
||||||
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() {
|
applyAudioState() {
|
||||||
if (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);
|
||||||
this.localStream.getAudioTracks().forEach(track => {
|
|
||||||
track.enabled = shouldTransmit;
|
|
||||||
});
|
|
||||||
|
|
||||||
|
// 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]) => {
|
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) {
|
||||||
if (this.isWhispering) {
|
if (this.isWhispering) {
|
||||||
sender.track.enabled = this.whisperPeers.has(pid);
|
sender.track.enabled = shouldTransmit && this.whisperPeers.has(pid);
|
||||||
} else {
|
} else {
|
||||||
sender.track.enabled = !!this.participants[pid];
|
// FIX: Ensure track is only enabled if shouldTransmit is true
|
||||||
|
sender.track.enabled = shouldTransmit && !!this.participants[pid];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -621,35 +509,31 @@ class VoiceChannel {
|
|||||||
|
|
||||||
setMute(mute) {
|
setMute(mute) {
|
||||||
this.isSelfMuted = mute;
|
this.isSelfMuted = mute;
|
||||||
this.applyAudioState();
|
this.updateMuteState();
|
||||||
|
this.applyAudioState(); // Always update UI even if not "talking"
|
||||||
}
|
}
|
||||||
|
toggleMute() { if (this.canSpeak !== false) this.setMute(!this.isSelfMuted); }
|
||||||
toggleMute() {
|
|
||||||
if (this.canSpeak === false) return;
|
|
||||||
this.setMute(!this.isSelfMuted);
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleDeafen() {
|
toggleDeafen() {
|
||||||
this.isDeafened = !this.isDeafened;
|
this.isDeafened = !this.isDeafened;
|
||||||
Object.values(this.remoteAudios).forEach(audio => {
|
Object.values(this.remoteAudios).forEach(audio => {
|
||||||
audio.muted = this.isDeafened;
|
audio.muted = this.isDeafened;
|
||||||
if (!this.isDeafened) audio.volume = this.settings.outputVolume || 1.0;
|
if (!this.isDeafened) audio.volume = this.settings.outputVolume || 1.0;
|
||||||
});
|
});
|
||||||
if (this.isDeafened && !this.isSelfMuted) {
|
if (this.isDeafened && !this.isSelfMuted) this.setMute(true);
|
||||||
this.setMute(true);
|
else {
|
||||||
|
this.applyAudioState();
|
||||||
}
|
}
|
||||||
this.applyAudioState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setOutputVolume(vol) {
|
setOutputVolume(vol) {
|
||||||
this.settings.outputVolume = parseFloat(vol);
|
this.settings.outputVolume = parseFloat(vol);
|
||||||
Object.values(this.remoteAudios).forEach(audio => {
|
Object.values(this.remoteAudios).forEach(audio => { audio.volume = this.settings.outputVolume; });
|
||||||
audio.volume = this.settings.outputVolume;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setInputVolume(vol) {
|
setInputVolume(vol) {
|
||||||
this.settings.inputVolume = parseFloat(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) {
|
async setInputDevice(deviceId) {
|
||||||
@ -657,16 +541,9 @@ 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 };
|
||||||
const newStream = await navigator.mediaDevices.getUserMedia(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.getTracks().forEach(t => t.stop());
|
||||||
this.localStream = newStream;
|
this.localStream = newStream;
|
||||||
this.setupVOX();
|
await this.setupVOX();
|
||||||
this.applyAudioState();
|
this.applyAudioState();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -674,9 +551,7 @@ class VoiceChannel {
|
|||||||
async setOutputDevice(deviceId) {
|
async setOutputDevice(deviceId) {
|
||||||
this.settings.outputDevice = deviceId;
|
this.settings.outputDevice = deviceId;
|
||||||
Object.values(this.remoteAudios).forEach(audio => {
|
Object.values(this.remoteAudios).forEach(audio => {
|
||||||
if (typeof audio.setSinkId === 'function') {
|
if (typeof audio.setSinkId === 'function') audio.setSinkId(deviceId).catch(e => console.error(e));
|
||||||
audio.setSinkId(deviceId).catch(e => console.error('setSinkId failed:', e));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -684,31 +559,20 @@ 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);
|
||||||
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.getTracks().forEach(t => t.stop());
|
||||||
this.localStream = newStream;
|
this.localStream = newStream;
|
||||||
this.setupVOX();
|
await this.setupVOX();
|
||||||
this.applyAudioState();
|
this.applyAudioState();
|
||||||
} catch (e) {
|
} catch (e) { console.error(e); }
|
||||||
console.error('Failed to update audio constraints:', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateUserPanelButtons() {
|
updateUserPanelButtons() {
|
||||||
const btnMute = document.getElementById('btn-panel-mute');
|
const btnMute = document.getElementById('btn-panel-mute');
|
||||||
const btnDeafen = document.getElementById('btn-panel-deafen');
|
const btnDeafen = document.getElementById('btn-panel-deafen');
|
||||||
|
let displayMuted = this.isSelfMuted || this.canSpeak === false;
|
||||||
let displayMuted = this.isSelfMuted;
|
|
||||||
if (this.canSpeak === false) displayMuted = true;
|
|
||||||
|
|
||||||
if (btnMute) {
|
if (btnMute) {
|
||||||
btnMute.classList.toggle('active', displayMuted);
|
btnMute.classList.toggle('active', displayMuted);
|
||||||
btnMute.style.color = displayMuted ? '#f23f43' : 'var(--text-muted)';
|
btnMute.style.color = displayMuted ? '#f23f43' : 'var(--text-muted)';
|
||||||
@ -716,7 +580,6 @@ class VoiceChannel {
|
|||||||
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="1" y1="1" x2="23" y2="23"></line><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line></svg>' :
|
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="1" y1="1" x2="23" y2="23"></line><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line></svg>' :
|
||||||
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="19" x2="16" y2="19"></line></svg>';
|
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="19" x2="16" y2="19"></line></svg>';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (btnDeafen) {
|
if (btnDeafen) {
|
||||||
btnDeafen.classList.toggle('active', this.isDeafened);
|
btnDeafen.classList.toggle('active', this.isDeafened);
|
||||||
btnDeafen.style.color = this.isDeafened ? '#f23f43' : 'var(--text-muted)';
|
btnDeafen.style.color = this.isDeafened ? '#f23f43' : 'var(--text-muted)';
|
||||||
@ -730,47 +593,20 @@ class VoiceChannel {
|
|||||||
if (!this.currentChannelId) return;
|
if (!this.currentChannelId) return;
|
||||||
const cid = this.currentChannelId;
|
const cid = this.currentChannelId;
|
||||||
const pid = this.myPeerId;
|
const pid = this.myPeerId;
|
||||||
|
|
||||||
sessionStorage.removeItem('activeVoiceChannel');
|
sessionStorage.removeItem('activeVoiceChannel');
|
||||||
sessionStorage.removeItem('activeVoicePeerId');
|
sessionStorage.removeItem('activeVoicePeerId');
|
||||||
if (this.pollInterval) clearInterval(this.pollInterval);
|
if (this.pollInterval) clearInterval(this.pollInterval);
|
||||||
|
|
||||||
fetch(`api_v1_voice.php?action=leave&room=${cid}&peer_id=${pid}`, { keepalive: true });
|
fetch(`api_v1_voice.php?action=leave&room=${cid}&peer_id=${pid}`, { keepalive: true });
|
||||||
|
if (this.localStream) this.localStream.getTracks().forEach(track => track.stop());
|
||||||
if (this.localStream) {
|
if (this.processedStream) this.processedStream.getTracks().forEach(track => track.stop());
|
||||||
this.localStream.getTracks().forEach(track => track.stop());
|
if (this.analysisStream) this.analysisStream.getTracks().forEach(track => track.stop());
|
||||||
this.localStream = null;
|
if (this.scriptProcessor) { try { this.scriptProcessor.disconnect(); this.scriptProcessor.onaudioprocess = null; } catch(e) {} }
|
||||||
}
|
if (this.rnnoiseNode) { try { this.rnnoiseNode.disconnect(); } catch(e) {} }
|
||||||
if (this.analysisStream) {
|
if (this.inputGainNode) { try { this.inputGainNode.disconnect(); } catch(e) {} }
|
||||||
this.analysisStream.getTracks().forEach(track => track.stop());
|
if (this.microphone) { try { this.microphone.disconnect(); } catch(e) {} }
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.values(this.peers).forEach(pc => pc.close());
|
Object.values(this.peers).forEach(pc => pc.close());
|
||||||
Object.values(this.remoteAudios).forEach(audio => {
|
Object.values(this.remoteAudios).forEach(audio => { audio.pause(); audio.remove(); audio.srcObject = null; });
|
||||||
audio.pause();
|
this.peers = {}; this.remoteAudios = {}; this.participants = {}; this.currentChannelId = null; this.myPeerId = null; this.speakingUsers.clear();
|
||||||
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'));
|
document.querySelectorAll('.voice-item').forEach(el => el.classList.remove('active'));
|
||||||
this.updateVoiceUI();
|
this.updateVoiceUI();
|
||||||
}
|
}
|
||||||
@ -791,8 +627,7 @@ class VoiceChannel {
|
|||||||
<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.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>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>`;
|
||||||
`;
|
|
||||||
const sidebar = document.querySelector('.channels-sidebar');
|
const sidebar = document.querySelector('.channels-sidebar');
|
||||||
if (sidebar) sidebar.appendChild(controls);
|
if (sidebar) sidebar.appendChild(controls);
|
||||||
const btnLeave = document.getElementById('btn-voice-leave');
|
const btnLeave = document.getElementById('btn-voice-leave');
|
||||||
@ -806,22 +641,11 @@ class VoiceChannel {
|
|||||||
|
|
||||||
updateSpeakingUI(userId, isSpeaking, isWhisper = false) {
|
updateSpeakingUI(userId, isSpeaking, isWhisper = false) {
|
||||||
userId = String(userId);
|
userId = String(userId);
|
||||||
if (isSpeaking) {
|
if (isSpeaking) this.speakingUsers.add(userId); else this.speakingUsers.delete(userId);
|
||||||
this.speakingUsers.add(userId);
|
|
||||||
} else {
|
|
||||||
this.speakingUsers.delete(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const userEls = document.querySelectorAll(`.voice-user[data-user-id="${userId}"]`);
|
const userEls = document.querySelectorAll(`.voice-user[data-user-id="${userId}"]`);
|
||||||
userEls.forEach(el => {
|
userEls.forEach(el => {
|
||||||
const avatar = el.querySelector('.message-avatar');
|
const avatar = el.querySelector('.message-avatar');
|
||||||
if (avatar) {
|
if (avatar) avatar.style.boxShadow = isSpeaking ? (isWhisper ? '0 0 0 2px #00a8fc' : '0 0 0 2px #23a559') : 'none';
|
||||||
if (isSpeaking) {
|
|
||||||
avatar.style.boxShadow = isWhisper ? '0 0 0 2px #00a8fc' : '0 0 0 2px #23a559';
|
|
||||||
} else {
|
|
||||||
avatar.style.boxShadow = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isWhisper && isSpeaking && userId !== String(window.currentUserId)) {
|
if (isWhisper && isSpeaking && userId !== String(window.currentUserId)) {
|
||||||
if (!el.querySelector('.whisper-label')) {
|
if (!el.querySelector('.whisper-label')) {
|
||||||
const label = document.createElement('span');
|
const label = document.createElement('span');
|
||||||
@ -830,10 +654,7 @@ class VoiceChannel {
|
|||||||
label.innerText = 'WHISPER';
|
label.innerText = 'WHISPER';
|
||||||
el.querySelector('span.text-truncate').after(label);
|
el.querySelector('span.text-truncate').after(label);
|
||||||
}
|
}
|
||||||
} else {
|
} else { const label = el.querySelector('.whisper-label'); if (label) label.remove(); }
|
||||||
const label = el.querySelector('.whisper-label');
|
|
||||||
if (label) label.remove();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -844,13 +665,10 @@ class VoiceChannel {
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
document.querySelectorAll('.voice-users-list').forEach(el => el.innerHTML = '');
|
document.querySelectorAll('.voice-users-list').forEach(el => el.innerHTML = '');
|
||||||
document.querySelectorAll('.voice-item').forEach(el => el.classList.remove('connected'));
|
document.querySelectorAll('.voice-item').forEach(el => el.classList.remove('connected'));
|
||||||
|
|
||||||
Object.keys(data.channels).forEach(channelId => {
|
Object.keys(data.channels).forEach(channelId => {
|
||||||
const voiceItem = document.querySelector(`.voice-item[data-channel-id="${channelId}"]`);
|
const voiceItem = document.querySelector(`.voice-item[data-channel-id="${channelId}"]`);
|
||||||
if (voiceItem) {
|
if (voiceItem) {
|
||||||
if (window.voiceHandler && window.voiceHandler.currentChannelId == channelId) {
|
if (window.voiceHandler && window.voiceHandler.currentChannelId == channelId) voiceItem.classList.add('connected');
|
||||||
voiceItem.classList.add('connected');
|
|
||||||
}
|
|
||||||
const container = voiceItem.closest('.channel-item-container');
|
const container = voiceItem.closest('.channel-item-container');
|
||||||
if (container) {
|
if (container) {
|
||||||
const listEl = container.querySelector('.voice-users-list');
|
const listEl = container.querySelector('.voice-users-list');
|
||||||
@ -865,9 +683,7 @@ class VoiceChannel {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) { console.error('Failed refresh voice users:', e); }
|
||||||
console.error('Failed to refresh voice users:', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static renderUserToUI(container, userId, username, avatarUrl, isSpeaking = false, isMuted = false, isDeafened = false) {
|
static renderUserToUI(container, userId, username, avatarUrl, isSpeaking = false, isMuted = false, isDeafened = false) {
|
||||||
@ -877,16 +693,13 @@ class VoiceChannel {
|
|||||||
userEl.style.paddingLeft = '8px';
|
userEl.style.paddingLeft = '8px';
|
||||||
const avatarStyle = avatarUrl ? `background-image: url('${avatarUrl}'); background-size: cover;` : "background-color: #555;";
|
const avatarStyle = avatarUrl ? `background-image: url('${avatarUrl}'); background-size: cover;` : "background-color: #555;";
|
||||||
const boxShadow = isSpeaking ? 'box-shadow: 0 0 0 2px #23a559;' : '';
|
const boxShadow = isSpeaking ? 'box-shadow: 0 0 0 2px #23a559;' : '';
|
||||||
|
|
||||||
let icons = '';
|
let icons = '';
|
||||||
if (isDeafened) icons += '<i class="fa-solid fa-volume-xmark ms-auto text-danger" style="font-size: 10px;"></i>';
|
if (isDeafened) icons += '<i class="fa-solid fa-volume-xmark ms-auto text-danger" style="font-size: 10px;"></i>';
|
||||||
else if (isMuted) icons += '<i class="fa-solid fa-microphone-slash ms-auto text-danger" style="font-size: 10px;"></i>';
|
else if (isMuted) icons += '<i class="fa-solid fa-microphone-slash ms-auto text-danger" style="font-size: 10px;"></i>';
|
||||||
|
|
||||||
userEl.innerHTML = `
|
userEl.innerHTML = `
|
||||||
<div class="message-avatar me-2" style="width: 16px; height: 16px; border-radius: 50%; transition: box-shadow 0.2s; ${avatarStyle} ${boxShadow}"></div>
|
<div class="message-avatar me-2" style="width: 16px; height: 16px; border-radius: 50%; transition: box-shadow 0.2s; ${avatarStyle} ${boxShadow}"></div>
|
||||||
<span class="text-truncate" style="font-size: 13px; max-width: 100px;">${username}</span>
|
<span class="text-truncate" style="font-size: 13px; max-width: 100px;">${username}</span>
|
||||||
${icons}
|
${icons}`;
|
||||||
`;
|
|
||||||
container.appendChild(userEl);
|
container.appendChild(userEl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1880,8 +1880,9 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label text-uppercase fw-bold mb-2" style="font-size: 0.7em; color: var(--text-muted);">Volume d'entrée</label>
|
<label class="form-label text-uppercase fw-bold mb-2" style="font-size: 0.7em; color: var(--text-muted);">Volume d.entrée (Amplification)</label>
|
||||||
<input type="range" name="voice_input_volume" id="voice_input_volume" class="form-range" min="0" max="1" step="0.01" value="1.0">
|
<input type="range" name="voice_input_volume" id="voice_input_volume" class="form-range" min="0" max="4" step="0.01" value="1.0">
|
||||||
|
<div class="d-flex justify-content-between small text-muted"><span>0%</span><span>100%</span><span>400%</span></div>
|
||||||
<script>document.getElementById('voice_input_volume').value = localStorage.getItem('voice_input_volume') || 1.0;</script>
|
<script>document.getElementById('voice_input_volume').value = localStorage.getItem('voice_input_volume') || 1.0;</script>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user