1
0
mirror of https://github.com/KubaPro010/fm-dx-webserver.git synced 2026-02-26 22:13:53 +01:00

Merge branch 'main' into main

This commit is contained in:
Adam Wisher
2025-09-02 09:27:54 +01:00
committed by GitHub
15 changed files with 803 additions and 385 deletions

48
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "fm-dx-webserver", "name": "fm-dx-webserver",
"version": "1.3.9", "version": "1.3.10",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "fm-dx-webserver", "name": "fm-dx-webserver",
"version": "1.3.9", "version": "1.3.10",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@mapbox/node-pre-gyp": "2.0.0", "@mapbox/node-pre-gyp": "2.0.0",
@@ -16,7 +16,6 @@
"express-session": "1.18.1", "express-session": "1.18.1",
"ffmpeg-static": "5.2.0", "ffmpeg-static": "5.2.0",
"http": "0.0.1-security", "http": "0.0.1-security",
"http-proxy": "1.18.1",
"koffi": "2.7.2", "koffi": "2.7.2",
"net": "1.0.2", "net": "1.0.2",
"serialport": "12.0.0", "serialport": "12.0.0",
@@ -800,11 +799,6 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
},
"node_modules/express": { "node_modules/express": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
@@ -985,26 +979,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/forwarded": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1132,19 +1106,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/http-proxy": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
"dependencies": {
"eventemitter3": "^4.0.0",
"follow-redirects": "^1.0.0",
"requires-port": "^1.0.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/http-response-object": { "node_modules/http-response-object": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz", "resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz",
@@ -1561,11 +1522,6 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
},
"node_modules/router": { "node_modules/router": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "fm-dx-webserver", "name": "fm-dx-webserver",
"version": "1.3.9", "version": "1.3.10",
"description": "FM DX Webserver", "description": "FM DX Webserver",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -19,7 +19,6 @@
"express-session": "1.18.1", "express-session": "1.18.1",
"ffmpeg-static": "5.2.0", "ffmpeg-static": "5.2.0",
"http": "0.0.1-security", "http": "0.0.1-security",
"http-proxy": "1.18.1",
"koffi": "2.7.2", "koffi": "2.7.2",
"net": "1.0.2", "net": "1.0.2",
"serialport": "12.0.0", "serialport": "12.0.0",

View File

@@ -272,7 +272,7 @@ function rdsReceived() {
rdsTimeoutTimer = null; rdsTimeoutTimer = null;
} }
if (serverConfig.webserver.rdsTimeout && serverConfig.webserver.rdsTimeout != 0) { if (serverConfig.webserver.rdsTimeout && serverConfig.webserver.rdsTimeout != 0) {
rdsTimeoutTimer = setInterval(rdsReset, serverConfig.webserver.rdsTimeout * 1000); rdsTimeoutTimer = setTimeout(rdsReset, serverConfig.webserver.rdsTimeout * 1000);
} }
} }

View File

@@ -21,7 +21,7 @@ router.get('/', (req, res) => {
let requestIp = req.headers['x-forwarded-for'] || req.connection.remoteAddress; let requestIp = req.headers['x-forwarded-for'] || req.connection.remoteAddress;
const normalizedIp = requestIp?.replace(/^::ffff:/, ''); const normalizedIp = requestIp?.replace(/^::ffff:/, '');
const ipList = normalizedIp.split(',').map(ip => ip.trim()); // in case there are multiple IPs (proxy), we need to check all of them const ipList = (normalizedIp || '').split(',').map(ip => ip.trim()).filter(Boolean); // in case there are multiple IPs (proxy), we need to check all of them
const isBanned = ipList.some(ip => serverConfig.webserver.banlist.some(banEntry => banEntry[0] === ip)); const isBanned = ipList.some(ip => serverConfig.webserver.banlist.some(banEntry => banEntry[0] === ip));
@@ -140,12 +140,14 @@ router.get('/wizard', (req, res) => {
audioDevices: result.videoDevices, audioDevices: result.videoDevices,
serialPorts: serialPorts, serialPorts: serialPorts,
memoryUsage: (process.memoryUsage.rss() / 1024 / 1024).toFixed(1) + ' MB', memoryUsage: (process.memoryUsage.rss() / 1024 / 1024).toFixed(1) + ' MB',
memoryHeap: (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1) + ' MB',
processUptime: formattedProcessUptime, processUptime: formattedProcessUptime,
consoleOutput: logs, consoleOutput: logs,
plugins: allPluginConfigs, plugins: allPluginConfigs,
enabledPlugins: updatedConfig.plugins, enabledPlugins: updatedConfig.plugins,
onlineUsers: dataHandler.dataToSend.users, onlineUsers: dataHandler.dataToSend.users,
connectedUsers: storage.connectedUsers, connectedUsers: storage.connectedUsers,
device: serverConfig.device,
banlist: updatedConfig.webserver.banlist // Updated banlist from the latest config banlist: updatedConfig.webserver.banlist // Updated banlist from the latest config
}); });
}); });
@@ -369,6 +371,14 @@ const logHistory = {};
function canLog(id) { function canLog(id) {
const now = Date.now(); const now = Date.now();
const sixtyMinutes = 60 * 60 * 1000; // 60 minutes in milliseconds const sixtyMinutes = 60 * 60 * 1000; // 60 minutes in milliseconds
// Remove expired entries
for (const [entryId, timestamp] of Object.entries(logHistory)) {
if ((now - timestamp) >= sixtyMinutes) {
delete logHistory[entryId];
}
}
if (logHistory[id] && (now - logHistory[id]) < sixtyMinutes) { if (logHistory[id] && (now - logHistory[id]) < sixtyMinutes) {
return false; // Deny logging if less than 60 minutes have passed return false; // Deny logging if less than 60 minutes have passed
} }

View File

@@ -4,7 +4,6 @@ const endpoints = require('./endpoints');
const session = require('express-session'); const session = require('express-session');
const bodyParser = require('body-parser'); const bodyParser = require('body-parser');
const http = require('http'); const http = require('http');
const httpProxy = require('http-proxy');
const readline = require('readline'); const readline = require('readline');
const app = express(); const app = express();
const httpServer = http.createServer(app); const httpServer = http.createServer(app);
@@ -18,6 +17,7 @@ const path = require('path');
const net = require('net'); const net = require('net');
const client = new net.Socket(); const client = new net.Socket();
const { SerialPort } = require('serialport'); const { SerialPort } = require('serialport');
const audioServer = require('./stream/3las.server');
const tunnel = require('./tunnel'); const tunnel = require('./tunnel');
// File imports // File imports
@@ -94,15 +94,9 @@ console.log('\x1b[90m' + '─'.repeat(terminalWidth - 1) + '\x1b[0m');
require('./stream/index'); require('./stream/index');
require('./plugins'); require('./plugins');
// Create a WebSocket proxy instance
const proxy = httpProxy.createProxyServer({
target: 'ws://localhost:' + (Number(serverConfig.webserver.webserverPort) + 10), // WebSocket httpServer's address
ws: true, // Enable WebSocket proxying
changeOrigin: true // Change the origin of the host header to the target URL
});
let currentUsers = 0; let currentUsers = 0;
let serialport; let serialport;
let timeoutAntenna;
app.use(bodyParser.urlencoded({ extended: true })); app.use(bodyParser.urlencoded({ extended: true }));
const sessionMiddleware = session({ const sessionMiddleware = session({
@@ -171,7 +165,7 @@ if (serverConfig.xdrd.wirelessConnection === false) {
setTimeout(() => { setTimeout(() => {
serialport.write('Q0\n'); serialport.write('Q0\n');
serialport.write('M0\n'); serialport.write('M0\n');
serialport.write('Z0\n'); serialport.write(`Z${serverConfig.antennaStartup}\n`); // Antenna on startup
if (serverConfig.defaultFreq && serverConfig.enableDefaultFreq === true) { if (serverConfig.defaultFreq && serverConfig.enableDefaultFreq === true) {
serialport.write('T' + Math.round(serverConfig.defaultFreq * 1000) + '\n'); serialport.write('T' + Math.round(serverConfig.defaultFreq * 1000) + '\n');
@@ -188,7 +182,20 @@ if (serverConfig.xdrd.wirelessConnection === false) {
serialport.write('F-1\n'); serialport.write('F-1\n');
serialport.write('W0\n'); serialport.write('W0\n');
serverConfig.webserver.rdsMode ? serialport.write('D1\n') : serialport.write('D0\n'); serverConfig.webserver.rdsMode ? serialport.write('D1\n') : serialport.write('D0\n');
serialport.write('G00\n'); // cEQ and iMS combinations
if (serverConfig.ceqStartup === "0" && serverConfig.imsStartup === "0") {
serialport.write("G00\n"); // Both Disabled
} else if (serverConfig.ceqStartup === "1" && serverConfig.imsStartup === "0") {
serialport.write(`G10\n`);
} else if (serverConfig.ceqStartup === "0" && serverConfig.imsStartup === "1") {
serialport.write(`G01\n`);
} else if (serverConfig.ceqStartup === "1" && serverConfig.imsStartup === "1") {
serialport.write("G11\n"); // Both Enabled
}
// Handle stereo mode
if (serverConfig.stereoStartup === "1") {
serialport.write("B1\n"); // Mono
}
serverConfig.audio.startupVolume serverConfig.audio.startupVolume
? serialport.write('Y' + (serverConfig.audio.startupVolume * 100).toFixed(0) + '\n') ? serialport.write('Y' + (serverConfig.audio.startupVolume * 100).toFixed(0) + '\n')
: serialport.write('Y100\n'); : serialport.write('Y100\n');
@@ -326,63 +333,28 @@ app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, '../web')); app.set('views', path.join(__dirname, '../web'));
app.use('/', endpoints); app.use('/', endpoints);
function antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, lengthCommands, endpointName) {
const command = message.toString();
const now = Date.now();
if (endpointName === 'text') logDebug(`Command received from \x1b[90m${clientIp}\x1b[0m: ${command}`);
// Initialize user command history if not present
if (!userCommandHistory[clientIp]) {
userCommandHistory[clientIp] = [];
}
// Record the current timestamp for the user
userCommandHistory[clientIp].push(now);
// Remove timestamps older than 20 ms from the history
userCommandHistory[clientIp] = userCommandHistory[clientIp].filter(timestamp => now - timestamp <= 20);
// Check if there are 8 or more commands in the last 20 ms
if (userCommandHistory[clientIp].length >= 8) {
logWarn(`User \x1b[90m${clientIp}\x1b[0m is spamming with rapid commands. Connection will be terminated and user will be banned.`);
// Add to banlist if not already banned
if (!serverConfig.webserver.banlist.includes(clientIp)) {
serverConfig.webserver.banlist.push(clientIp);
logInfo(`User \x1b[90m${clientIp}\x1b[0m has been added to the banlist due to extreme spam.`);
console.log(serverConfig.webserver.banlist);
configSave();
}
ws.close(1008, 'Bot-like behavior detected');
return command; // Return command value before closing connection
}
// Update the last message time for general spam detection
lastMessageTime = now;
// Initialize command history for rate-limiting checks
if (!userCommands[command]) {
userCommands[command] = [];
}
// Record the current timestamp for this command
userCommands[command].push(now);
// Remove timestamps older than 1 second
userCommands[command] = userCommands[command].filter(timestamp => now - timestamp <= 1000);
// If command count exceeds limit, close connection
if (userCommands[command].length > lengthCommands) {
if (now - lastWarn.time > 1000) { // Check if 1 second has passed
logWarn(`User \x1b[90m${clientIp}\x1b[0m is spamming command "${command}" in /${endpointName}. Connection will be terminated.`);
lastWarn.time = now; // Update the last warning time
}
ws.close(1008, 'Spamming detected');
return command; // Return command value before closing connection
}
return command; // Return command value for normal execution
}
/** /**
* WEBSOCKET BLOCK * WEBSOCKET BLOCK
*/ */
const tunerLockTracker = new WeakMap(); const tunerLockTracker = new WeakMap();
const ipConnectionCounts = new Map(); // Per-IP limit variables
const ipLogTimestamps = new Map();
const MAX_CONNECTIONS_PER_IP = 5;
const IP_LOG_INTERVAL_MS = 60000;
// Remove old per-IP limit addresses
setInterval(() => {
const now = Date.now();
for (const [ip, count] of ipConnectionCounts.entries()) {
const lastSeen = ipLogTimestamps.get(ip) || 0;
const inactive = now - lastSeen > 60 * 60 * 1000;
if (count === 0 && inactive) {
ipConnectionCounts.delete(ip);
ipLogTimestamps.delete(ip);
}
}
}, 60 * 60 * 1000); // Run every hour
wss.on('connection', (ws, request) => { wss.on('connection', (ws, request) => {
const output = serverConfig.xdrd.wirelessConnection ? client : serialport; const output = serverConfig.xdrd.wirelessConnection ? client : serialport;
@@ -390,19 +362,50 @@ wss.on('connection', (ws, request) => {
const userCommandHistory = {}; const userCommandHistory = {};
const normalizedClientIp = clientIp?.replace(/^::ffff:/, ''); const normalizedClientIp = clientIp?.replace(/^::ffff:/, '');
if (serverConfig.webserver.banlist?.includes(clientIp)) { if (clientIp && serverConfig.webserver.banlist?.includes(clientIp)) {
ws.close(1008, 'Banned IP'); ws.close(1008, 'Banned IP');
return; return;
} }
if (clientIp.includes(',')) { if (clientIp && clientIp.includes(',')) {
clientIp = clientIp.split(',')[0].trim(); clientIp = clientIp.split(',')[0].trim();
} }
// Per-IP limit connection open
if (clientIp) {
const isLocalIp = (
clientIp === '127.0.0.1' ||
clientIp === '::1' ||
clientIp === '::ffff:127.0.0.1' ||
clientIp.startsWith('192.168.') ||
clientIp.startsWith('10.') ||
clientIp.startsWith('172.16.')
);
if (!isLocalIp) {
if (!ipConnectionCounts.has(clientIp)) {
ipConnectionCounts.set(clientIp, 0);
}
const currentCount = ipConnectionCounts.get(clientIp);
if (currentCount >= MAX_CONNECTIONS_PER_IP) {
ws.close(1008, 'Too many open connections from this IP');
const lastLogTime = ipLogTimestamps.get(clientIp) || 0;
const now = Date.now();
if (now - lastLogTime > IP_LOG_INTERVAL_MS) {
logWarn(`Web client \x1b[31mclosed: limit exceeded\x1b[0m (${normalizedClientIp}) \x1b[90m[${currentUsers}]`);
ipLogTimestamps.set(clientIp, now);
}
return;
}
ipConnectionCounts.set(clientIp, currentCount + 1);
}
}
if (clientIp !== '::ffff:127.0.0.1' || (request.connection && request.connection.remoteAddress && request.connection.remoteAddress !== '::ffff:127.0.0.1') || (request.headers && request.headers['origin'] && request.headers['origin'].trim() !== '')) { if (clientIp !== '::ffff:127.0.0.1' || (request.connection && request.connection.remoteAddress && request.connection.remoteAddress !== '::ffff:127.0.0.1') || (request.headers && request.headers['origin'] && request.headers['origin'].trim() !== '')) {
currentUsers++; currentUsers++;
} }
if (timeoutAntenna) clearTimeout(timeoutAntenna);
helpers.handleConnect(clientIp, currentUsers, ws, (result) => { helpers.handleConnect(clientIp, currentUsers, ws, (result) => {
if (result === "User banned") { if (result === "User banned") {
ws.close(1008, 'Banned IP'); ws.close(1008, 'Banned IP');
@@ -472,6 +475,22 @@ wss.on('connection', (ws, request) => {
}); });
ws.on('close', (code, reason) => { ws.on('close', (code, reason) => {
// Per-IP limit connection closed
if (clientIp) {
const isLocalIp = (
clientIp === '127.0.0.1' ||
clientIp === '::1' ||
clientIp === '::ffff:127.0.0.1' ||
clientIp.startsWith('192.168.') ||
clientIp.startsWith('10.') ||
clientIp.startsWith('172.16.')
);
if (!isLocalIp) {
const current = ipConnectionCounts.get(clientIp) || 1;
ipConnectionCounts.set(clientIp, Math.max(0, current - 1));
}
}
if (clientIp !== '::ffff:127.0.0.1' || (request.connection && request.connection.remoteAddress && request.connection.remoteAddress !== '::ffff:127.0.0.1') || (request.headers && request.headers['origin'] && request.headers['origin'].trim() !== '')) { if (clientIp !== '::ffff:127.0.0.1' || (request.connection && request.connection.remoteAddress && request.connection.remoteAddress !== '::ffff:127.0.0.1') || (request.headers && request.headers['origin'] && request.headers['origin'].trim() !== '')) {
currentUsers--; currentUsers--;
} }
@@ -484,8 +503,46 @@ wss.on('connection', (ws, request) => {
if (currentUsers === 0) { if (currentUsers === 0) {
storage.connectedUsers = []; storage.connectedUsers = [];
output.write('W0\n');
output.write('B0\n'); if (serverConfig.bwAutoNoUsers === "1") {
output.write("W0\n"); // Auto BW 'Enabled'
}
// cEQ and iMS combinations
if (serverConfig.ceqNoUsers === "1" && serverConfig.imsNoUsers === "1") {
output.write("G00\n"); // Both Disabled
} else if (serverConfig.ceqNoUsers === "1" && serverConfig.imsNoUsers === "0") {
output.write(`G0${dataHandler.dataToSend.ims}\n`);
} else if (serverConfig.ceqNoUsers === "0" && serverConfig.imsNoUsers === "1") {
output.write(`G${dataHandler.dataToSend.eq}0\n`);
} else if (serverConfig.ceqNoUsers === "2" && serverConfig.imsNoUsers === "0") {
output.write(`G1${dataHandler.dataToSend.ims}\n`);
} else if (serverConfig.ceqNoUsers === "0" && serverConfig.imsNoUsers === "2") {
output.write(`G${dataHandler.dataToSend.eq}1\n`);
} else if (serverConfig.ceqNoUsers === "2" && serverConfig.imsNoUsers === "2") {
output.write("G11\n"); // Both Enabled
}
// Handle stereo mode
if (serverConfig.stereoNoUsers === "1") {
output.write("B0\n");
} else if (serverConfig.stereoNoUsers === "2") {
output.write("B1\n");
}
// Handle Antenna selection
if (timeoutAntenna) clearTimeout(timeoutAntenna);
timeoutAntenna = setTimeout(() => {
if (serverConfig.antennaNoUsers === "1") {
output.write("Z0\n");
} else if (serverConfig.antennaNoUsers === "2") {
output.write("Z1\n");
} else if (serverConfig.antennaNoUsers === "3") {
output.write("Z2\n");
} else if (serverConfig.antennaNoUsers === "4") {
output.write("Z3\n");
}
}, serverConfig.antennaNoUsersDelay ? 15000 : 0);
} }
if (tunerLockTracker.has(ws)) { if (tunerLockTracker.has(ws)) {
@@ -641,50 +698,6 @@ pluginsWss.on('connection', (ws, request) => {
}); });
}); });
// Additional web socket for using plugins
pluginsWss.on('connection', (ws, request) => {
const clientIp = request.headers['x-forwarded-for'] || request.connection.remoteAddress;
const userCommandHistory = {};
if (serverConfig.webserver.banlist?.includes(clientIp)) {
ws.close(1008, 'Banned IP');
return;
}
// Anti-spam tracking for each client
const userCommands = {};
let lastWarn = { time: 0 };
ws.on('message', message => {
// Anti-spam
const command = antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, '10', 'data_plugins');
let messageData;
try {
messageData = JSON.parse(message); // Attempt to parse the JSON
} catch (error) {
// console.error("Failed to parse message:", error); // Log the error
return; // Exit if parsing fails
}
const modifiedMessage = JSON.stringify(messageData);
// Broadcast the message to all other clients
pluginsWss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(modifiedMessage); // Send the message to all clients
}
});
});
ws.on('close', () => {
// logInfo('WebSocket Extra connection closed'); // Use custom logInfo function
});
ws.on('error', error => {
logError('WebSocket Extra error: ' + error); // Use custom logError function
});
});
function isPortOpen(host, port, timeout = 1000) { function isPortOpen(host, port, timeout = 1000) {
return new Promise((resolve) => { return new Promise((resolve) => {
const socket = new net.Socket(); const socket = new net.Socket();
@@ -714,15 +727,15 @@ httpServer.on('upgrade', (request, socket, head) => {
}); });
}); });
} else if (request.url === '/audio') { } else if (request.url === '/audio') {
isPortOpen('localhost', (Number(serverConfig.webserver.webserverPort) + 10)).then((open) => { if (typeof audioServer?.handleAudioUpgrade === 'function') {
if (open) { audioServer.handleAudioUpgrade(request, socket, head, (ws) => {
proxy.ws(request, socket, head); audioServer.Server?.Server?.emit?.('connection', ws, request);
} else { });
logWarn(`Audio stream port ${(Number(serverConfig.webserver.webserverPort) + 10)} not yet open — skipping proxy connection.`); } else {
socket.end(); // close socket so client isn't left hanging logWarn('[Audio WebSocket] Audio server not ready — dropping client connection.');
} socket.destroy();
}); }
} else if (request.url === '/chat') { } else if (request.url === '/chat') {
sessionMiddleware(request, {}, () => { sessionMiddleware(request, {}, () => {
chatWss.handleUpgrade(request, socket, head, (ws) => { chatWss.handleUpgrade(request, socket, head, (ws) => {
chatWss.emit('connection', ws, request); chatWss.emit('connection', ws, request);
@@ -733,21 +746,21 @@ httpServer.on('upgrade', (request, socket, head) => {
rdsWss.handleUpgrade(request, socket, head, (ws) => { rdsWss.handleUpgrade(request, socket, head, (ws) => {
rdsWss.emit('connection', ws, request); rdsWss.emit('connection', ws, request);
const clientIp = request.headers['x-forwarded-for'] || request.connection.remoteAddress; const clientIp = request.headers['x-forwarded-for'] || request.connection.remoteAddress;
const userCommandHistory = {}; const userCommandHistory = {};
if (serverConfig.webserver.banlist?.includes(clientIp)) { if (serverConfig.webserver.banlist?.includes(clientIp)) {
ws.close(1008, 'Banned IP'); ws.close(1008, 'Banned IP');
return; return;
} }
// Anti-spam tracking for each client // Anti-spam tracking for each client
const userCommands = {}; const userCommands = {};
let lastWarn = { time: 0 }; let lastWarn = { time: 0 };
ws.on('message', function incoming(message) { ws.on('message', function incoming(message) {
// Anti-spam // Anti-spam
const command = helpers.antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, '5', 'rds'); const command = helpers.antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, '5', 'rds');
}); });
}); });
}); });

View File

@@ -107,7 +107,18 @@ let serverConfig = {
autoShutdown: false, autoShutdown: false,
enableDefaultFreq: false, enableDefaultFreq: false,
defaultFreq: "87.5", defaultFreq: "87.5",
bwSwitch: false bwSwitch: false,
bwAutoStartup: "0",
bwAutoNoUsers: "0",
ceqStartup: "0",
ceqNoUsers: "0",
imsStartup: "0",
imsNoUsers: "0",
stereoStartup: "0",
stereoNoUsers: "0",
antennaStartup: "0",
antennaNoUsers: "0",
antennaNoUsersDelay: false
}; };
// Function to add missing fields without overwriting existing values // Function to add missing fields without overwriting existing values
@@ -156,7 +167,7 @@ function configUpdate(newConfig) {
function configSave() { function configSave() {
try { try {
fs.writeFileSync(configPath, JSON.stringify(serverConfig, null, 2)); fs.writeFileSync(configPath, JSON.stringify(serverConfig, null, 2));
logInfo('Server config saved successfully.'); setTimeout(() => logInfo('Server config saved successfully.'), 0);
} catch (err) { } catch (err) {
logError(err); logError(err);
} }

View File

@@ -1,15 +1,27 @@
"use strict"; "use strict";
var fs = require('fs');
const checkFFmpeg = require('./checkFFmpeg');
const {serverConfig} = require('../server_config');
let ffmpegStaticPath;
function runStream() {
/* /*
Stdin streamer is part of 3LAS (Low Latency Live Audio Streaming) Stdin streamer is part of 3LAS (Low Latency Live Audio Streaming)
https://github.com/JoJoBond/3LAS https://github.com/JoJoBond/3LAS
*/ */
var fs = require('fs');
const path = require('path');
const checkFFmpeg = require('./checkFFmpeg');
const { spawn } = require('child_process');
const { logDebug, logError, logInfo, logWarn, logFfmpeg } = require('../console');
const { serverConfig } = require('../server_config');
let ffmpegStaticPath = 'ffmpeg'; // fallback value
let ServerInstance;
let handleAudioUpgradeFn;
let readyResolve;
const waitUntilReady = new Promise((resolve) => {
readyResolve = resolve;
});
checkFFmpeg().then((resolvedPath) => {
ffmpegStaticPath = resolvedPath;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k; if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
@@ -42,7 +54,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
const fs_1 = require("fs"); const fs_1 = require("fs");
const child_process_1 = require("child_process"); const child_process_1 = require("child_process");
const ws = __importStar(require("ws")); const ws = __importStar(require("ws"));
const Settings = JSON.parse((0, fs_1.readFileSync)('server/stream/settings.json', 'utf-8')); const Settings = JSON.parse(fs.readFileSync(path.resolve(__dirname, 'settings.json'), 'utf-8'));
const FFmpeg_command = ffmpegStaticPath; const FFmpeg_command = ffmpegStaticPath;
class StreamClient { class StreamClient {
constructor(server, socket) { constructor(server, socket) {
@@ -117,7 +129,7 @@ class StreamClient {
} }
class StreamServer { class StreamServer {
constructor(port, channels, sampleRate) { constructor(port, channels, sampleRate) {
this.Port = port; this.Port = port || null;
this.Channels = channels; this.Channels = channels;
this.SampleRate = sampleRate; this.SampleRate = sampleRate;
this.Clients = new Set(); this.Clients = new Set();
@@ -139,12 +151,21 @@ class StreamServer {
} }
Run() { Run() {
this.Server = new ws.Server({ this.Server = new ws.Server({
"host": ["127.0.0.1", "::1"], noServer: true,
"port": this.Port, clientTracking: true,
"clientTracking": true, perMessageDeflate: false,
"perMessageDeflate": false
}); });
// Allow manual upgrade handling from index.js
this.handleUpgrade = (req, socket, head) => {
this.Server.handleUpgrade(req, socket, head, (ws) => {
this.Server.emit('connection', ws, req);
});
};
this.Server.on('connection', this.OnServerConnection.bind(this)); this.Server.on('connection', this.OnServerConnection.bind(this));
if (!this.StdIn) {
logError('[Stream] No audio input stream defined (this.StdIn is null)');
return;
}
this.StdIn.on('data', this.OnStdInData.bind(this)); this.StdIn.on('data', this.OnStdInData.bind(this));
this.StdIn.resume(); this.StdIn.resume();
} }
@@ -195,6 +216,7 @@ class StreamServer {
"wav": (this.FallbackClients["wav"] ? this.FallbackClients["wav"].size : 0), "wav": (this.FallbackClients["wav"] ? this.FallbackClients["wav"].size : 0),
"mp3": (this.FallbackClients["mp3"] ? this.FallbackClients["mp3"].size : 0), "mp3": (this.FallbackClients["mp3"] ? this.FallbackClients["mp3"].size : 0),
}; };
let total = 0;
for (let format in fallback) { for (let format in fallback) {
total += fallback[format]; total += fallback[format];
} }
@@ -204,10 +226,8 @@ class StreamServer {
}; };
} }
static Create(options) { static Create(options) {
if (!options["-port"]) // Allow Port to be omitted
throw new Error("Port undefined. Please use -port to define the port."); const port = options["-port"] || null;
if (typeof options["-port"] !== "number" || options["-port"] !== Math.floor(options["-port"]) || options["-port"] < 1 || options["-port"] > 65535)
throw new Error("Invalid port. Must be natural number between 1 and 65535.");
if (!options["-channels"]) if (!options["-channels"])
throw new Error("Channels undefined. Please use -channels to define the number of channels."); throw new Error("Channels undefined. Please use -channels to define the number of channels.");
if (typeof options["-channels"] !== "number" || options["-channels"] !== Math.floor(options["-channels"]) || if (typeof options["-channels"] !== "number" || options["-channels"] !== Math.floor(options["-channels"]) ||
@@ -217,7 +237,7 @@ class StreamServer {
throw new Error("Sample rate undefined. Please use -samplerate to define the sample rate."); throw new Error("Sample rate undefined. Please use -samplerate to define the sample rate.");
if (typeof options["-samplerate"] !== "number" || options["-samplerate"] !== Math.floor(options["-samplerate"]) || options["-samplerate"] < 1) if (typeof options["-samplerate"] !== "number" || options["-samplerate"] !== Math.floor(options["-samplerate"]) || options["-samplerate"] < 1)
throw new Error("Invalid sample rate. Must be natural number greater than 0."); throw new Error("Invalid sample rate. Must be natural number greater than 0.");
return new StreamServer(options["-port"], options["-channels"], options["-samplerate"]); return new StreamServer(port, options["-channels"], options["-samplerate"]);
} }
} }
class AFallbackProvider { class AFallbackProvider {
@@ -328,12 +348,31 @@ for (let i = 2; i < (process.argv.length - 1); i += 2) {
throw new Error("Redefined argument: '" + process.argv[i] + "'. Please use '" + process.argv[i] + "' only ONCE"); throw new Error("Redefined argument: '" + process.argv[i] + "'. Please use '" + process.argv[i] + "' only ONCE");
Options[process.argv[i]] = OptionParser[process.argv[i]](process.argv[i + 1]); Options[process.argv[i]] = OptionParser[process.argv[i]](process.argv[i + 1]);
} }
const Server = StreamServer.Create(Options); const Server = new StreamServer(null, 2, 48000);
Server.Run();
//# sourceMappingURL=3las.server.js.map
}
checkFFmpeg().then((ffmpegResult) => { ServerInstance = Server;
ffmpegStaticPath = ffmpegResult;
runStream(); handleAudioUpgradeFn = function (request, socket, head, cb) {
}); if (Server.Server && Server.Server.handleUpgrade) {
Server.Server.handleUpgrade(request, socket, head, cb);
} else {
socket.destroy();
}
};
readyResolve();
}).catch((err) => {
logError('[Stream] Error:', err);
});
module.exports = {
get Server() {
return ServerInstance;
},
get handleAudioUpgrade() {
return handleAudioUpgradeFn;
},
waitUntilReady
};
//# sourceMappingURL=3las.server.js.map

View File

@@ -1,134 +1,394 @@
const { spawn, execSync } = require('child_process'); const { spawn, execSync } = require('child_process');
const { configName, serverConfig, configUpdate, configSave, configExists } = require('../server_config'); const { configName, serverConfig, configUpdate, configSave, configExists } = require('../server_config');
const { logDebug, logError, logInfo, logWarn, logFfmpeg } = require('../console'); const { logDebug, logError, logInfo, logWarn, logFfmpeg } = require('../console');
const checkFFmpeg = require('./checkFFmpeg'); const checkFFmpeg = require('./checkFFmpeg');
const audioServer = require('./3las.server');
let ffmpeg, ffmpegCommand, ffmpegParams;
const consoleLogTitle = '[Audio Stream]';
function checkAudioUtilities() {
if (process.platform === 'darwin') { let startupSuccess;
try {
execSync('which rec'); function connectMessage(message) {
//console.log('[Audio Utility Check] SoX ("rec") found.'); if (!startupSuccess) {
} catch (error) { logInfo(message);
logError('[Audio Utility Check] Error: SoX ("rec") not found. Please install SoX (e.g., using `brew install sox`).'); startupSuccess = true;
process.exit(1); // Exit the process with an error code }
} }
} else if (process.platform === 'linux') {
try { function checkAudioUtilities() {
execSync('which arecord'); if (process.platform === 'darwin') {
//console.log('[Audio Utility Check] ALSA ("arecord") found.'); try {
} catch (error) { execSync('which rec');
logError('[Audio Utility Check] Error: ALSA ("arecord") not found. Please ensure ALSA utilities are installed (e.g., using `sudo apt-get install alsa-utils` or `sudo yum install alsa-utils`).'); } catch (error) {
process.exit(1); // Exit the process with an error code logError(`${consoleLogTitle} Error: SoX ("rec") not found. Please install SoX.`);
} process.exit(1);
} else { }
//console.log(`[Audio Utility Check] Platform "${process.platform}" does not require explicit checks for rec or arecord.`); } else if (process.platform === 'linux') {
} try {
} execSync('which arecord');
} catch (error) {
function buildCommand() { logError(`${consoleLogTitle} Error: ALSA ("arecord") not found. Please install ALSA utils.`);
// Common audio options for FFmpeg process.exit(1);
const baseOptions = { }
flags: '-fflags +nobuffer+flush_packets -flags low_delay -rtbufsize 6192 -probesize 32', }
codec: `-acodec pcm_s16le -ar 48000 -ac ${serverConfig.audio.audioChannels}`, }
output: `${serverConfig.audio.audioBoost == true && serverConfig.audio.ffmpeg == true ? '-af "volume=3.5"' : ''} -f s16le -fflags +nobuffer+flush_packets -packetsize 384 -flush_packets 1 -bufsize 960`
}; function buildCommand(ffmpegPath) {
const inputDevice = serverConfig.audio.audioDevice || 'Stereo Mix';
if (process.platform === 'win32') { const audioChannels = serverConfig.audio.audioChannels || 2;
// Windows: ffmpeg using dshow const webPort = Number(serverConfig.webserver.webserverPort);
logInfo('[Audio Stream] Platform: Windows (win32). Using "dshow" input.');
ffmpegCommand = `"${ffmpeg.replace(/\\/g, '\\\\')}"`; // Common audio options for FFmpeg
return `${ffmpegCommand} ${baseOptions.flags} -f dshow -audio_buffer_size 200 -i audio="${serverConfig.audio.audioDevice}" ` + const baseOptions = {
`${baseOptions.codec} ${baseOptions.output} pipe:1 | node server/stream/3las.server.js -port ` + flags: ['-fflags', '+nobuffer+flush_packets', '-flags', 'low_delay', '-rtbufsize', '6192', '-probesize', '32'],
`${serverConfig.webserver.webserverPort + 10} -samplerate 48000 -channels ${serverConfig.audio.audioChannels}`; codec: ['-acodec', 'pcm_s16le', '-ar', '48000', '-ac', `${audioChannels}`],
} else if (process.platform === 'darwin') { output: ['-f', 's16le', '-fflags', '+nobuffer+flush_packets', '-packetsize', '384', '-flush_packets', '1', '-bufsize', '960', '-reconnect', '1', '-reconnect_streamed', '1', '-reconnect_delay_max', '10', 'pipe:1']
// macOS: using SoX's rec with coreaudio };
if (!serverConfig.audio.ffmpeg) {
logInfo('[Audio Stream] Platform: macOS (darwin) using "coreaudio" with the default audio device.'); // Windows
const recCommand = `rec -t coreaudio -b 32 -r 48000 -c ${serverConfig.audio.audioChannels} -t raw -b 16 -r 48000 -c ${serverConfig.audio.audioChannels} -`; if (process.platform === 'win32') {
return `${recCommand} | node server/stream/3las.server.js -port ${serverConfig.webserver.webserverPort + 10}` + logInfo(`${consoleLogTitle} Platform: Windows (win32). Using "dshow" input.`);
` -samplerate 48000 -channels ${serverConfig.audio.audioChannels}`; return {
} else { command: ffmpegPath,
ffmpegCommand = ffmpeg; args: [
ffmpegParams = `${baseOptions.flags} -f alsa -i "${serverConfig.audio.softwareMode && serverConfig.audio.softwareMode == true ? 'plug' : ''}${serverConfig.audio.audioDevice}" ${baseOptions.codec}`; ...baseOptions.flags,
ffmpegParams += ` ${baseOptions.output} -reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 10 pipe:1 | node server/stream/3las.server.js -port ${serverConfig.webserver.webserverPort + 10} -samplerate 48000 -channels ${serverConfig.audio.audioChannels}`; '-f', 'dshow',
return `${ffmpegCommand} ${ffmpegParams}`; '-audio_buffer_size', '200',
} '-i', `audio=${inputDevice}`,
} else { ...baseOptions.codec,
// Linux: use alsa with arecord ...baseOptions.output
// If softwareMode is enabled, prefix the device with 'plug' ]
if (!serverConfig.audio.ffmpeg) { };
const audioDevicePrefix = (serverConfig.audio.softwareMode && serverConfig.audio.softwareMode === true) ? 'plug' : ''; } else if (process.platform === 'darwin') {
logInfo('[Audio Stream] Platform: Linux. Using "alsa" input.'); // macOS
const recCommand = `while true; do arecord -D "${audioDevicePrefix}${serverConfig.audio.audioDevice}" -f S16_LE -r 48000 -c ${serverConfig.audio.audioChannels} -t raw -; done`; if (!serverConfig.audio.ffmpeg) {
return `${recCommand} | node server/stream/3las.server.js -port ${serverConfig.webserver.webserverPort + 10}` + logInfo(`${consoleLogTitle} Platform: macOS (darwin) using "coreaudio" with the default audio device.`);
` -samplerate 48000 -channels ${serverConfig.audio.audioChannels}`; return {
} else { // command not used if recArgs are used
ffmpegCommand = ffmpeg; command: `rec -t coreaudio -b 32 -r 48000 -c ${audioChannels} -t raw -b 16 -r 48000 -c ${audioChannels}`,
ffmpegParams = `${baseOptions.flags} -f alsa -i "${serverConfig.audio.softwareMode && serverConfig.audio.softwareMode == true ? 'plug' : ''}${serverConfig.audio.audioDevice}" ${baseOptions.codec}`; args: [],
ffmpegParams += ` ${baseOptions.output} -reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 10 pipe:1 | node server/stream/3las.server.js -port ${serverConfig.webserver.webserverPort + 10} -samplerate 48000 -channels ${serverConfig.audio.audioChannels}`; recArgs: [
return `${ffmpegCommand} ${ffmpegParams}`; '-t', 'coreaudio',
} '-b', '32',
} '-r', '48000',
} '-c', `${audioChannels}`,
'-t', 'raw',
function enableAudioStream() { '-b', '16',
// Ensure the webserver port is a number. '-r', '48000',
serverConfig.webserver.webserverPort = Number(serverConfig.webserver.webserverPort); '-c', `${audioChannels}`
let startupSuccess = false; ]
const command = buildCommand(); };
} else {
// Only log audio device details if the platform is not macOS. const device = serverConfig.audio.audioDevice;
if (process.platform !== 'darwin') { return {
logInfo(`Trying to start audio stream on device: \x1b[35m${serverConfig.audio.audioDevice}\x1b[0m`); command: ffmpegPath,
} args: [
else { ...baseOptions.flags,
// For macOS, log the default audio device. '-f', 'avfoundation',
logInfo(`Trying to start audio stream on default input device.`); '-i', `${device || ':0'}`,
} ...baseOptions.codec,
...baseOptions.output
logInfo(`Using internal audio network port: ${serverConfig.webserver.webserverPort + 10}`); ]
logInfo('Using', ffmpeg === 'ffmpeg' ? 'system-installed FFmpeg' : 'ffmpeg-static'); };
logDebug(`[Audio Stream] Full command:\n${command}`); }
} else {
// Start the stream only if a valid audio device is configured. // Linux
if (serverConfig.audio.audioDevice && serverConfig.audio.audioDevice.length > 2) { if (!serverConfig.audio.ffmpeg) {
const childProcess = spawn(command, { shell: true }); const prefix = serverConfig.audio.softwareMode ? 'plug' : '';
const device = `${prefix}${serverConfig.audio.audioDevice}`;
childProcess.stdout.on('data', (data) => { logInfo(`${consoleLogTitle} Platform: Linux. Using "alsa" input.`);
logFfmpeg(`[stream:stdout] ${data}`); return {
}); // command not used if arecordArgs are used
command: `while true; do arecord -D "${device}" -f S16_LE -r 48000 -c ${audioChannels} -t raw; done`,
childProcess.stderr.on('data', (data) => { args: [],
logFfmpeg(`[stream:stderr] ${data}`); arecordArgs: [
'-D', device,
if (data.includes('I/O error')) { '-f', 'S16_LE',
logError(`[Audio Stream] Audio device "${serverConfig.audio.audioDevice}" failed to start.`); '-r', '48000',
logError('Please start the server with: node . --ffmpegdebug for more info.'); '-c', audioChannels,
} '-t', 'raw'
if (data.includes('size=') && !startupSuccess) { ],
logInfo('[Audio Stream] Audio stream started up successfully.'); ffmpegArgs: []
startupSuccess = true; };
} } else {
}); const device = serverConfig.audio.audioDevice;
return {
childProcess.on('close', (code) => { command: ffmpegPath,
logFfmpeg(`[Audio Stream] Child process exited with code: ${code}`); args: [
}); ...baseOptions.flags,
'-f', 'alsa',
childProcess.on('error', (err) => { '-i', `${device}`,
logFfmpeg(`[Audio Stream] Error starting child process: ${err}`); ...baseOptions.codec,
}); ...baseOptions.output
} else { ],
logWarn('[Audio Stream] No valid audio device configured. Skipping audio stream initialization.'); arecordArgs: [],
} };
} }
}
if(configExists()) { }
checkFFmpeg().then((ffmpegResult) => {
ffmpeg = ffmpegResult; checkFFmpeg().then((ffmpegPath) => {
if (!serverConfig.audio.ffmpeg) checkAudioUtilities(); if (!serverConfig.audio.ffmpeg) checkAudioUtilities();
enableAudioStream(); let audioErrorLogged = false;
});
} logInfo(`${consoleLogTitle} Using`, ffmpegPath === 'ffmpeg' ? 'system-installed FFmpeg' : 'ffmpeg-static');
if (process.platform !== 'darwin') {
logInfo(`${consoleLogTitle} Starting audio stream on device: \x1b[35m${serverConfig.audio.audioDevice}\x1b[0m`);
} else {
logInfo(`${consoleLogTitle} Starting audio stream on default input device.`);
}
if (process.platform === 'win32') {
// Windows (FFmpeg DirectShow Capture)
let ffmpeg;
let restartTimer = null;
let lastTimestamp = null;
let lastCheckTime = Date.now();
let audioErrorLogged = false;
let staleCount = 0;
function launchFFmpeg() {
const commandDef = buildCommand(ffmpegPath);
let ffmpegArgs = commandDef.args;
// Apply audio boost if enabled
if (serverConfig.audio.audioBoost) {
ffmpegArgs.splice(ffmpegArgs.indexOf('pipe:1'), 0, '-af', 'volume=2.5');
}
logDebug(`${consoleLogTitle} Launching FFmpeg with args: ${ffmpegArgs.join(' ')}`);
ffmpeg = spawn(ffmpegPath, ffmpegArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
audioServer.waitUntilReady.then(() => {
audioServer.Server.StdIn = ffmpeg.stdout;
audioServer.Server.Run();
connectMessage(`${consoleLogTitle} Connected FFmpeg (capture) \u2192 FFmpeg (process) \u2192 Server.StdIn${serverConfig.audio.audioBoost ? ' (audio boost)' : ''}`);
});
ffmpeg.stderr.on('data', (data) => {
const msg = data.toString();
logFfmpeg(`[FFmpeg stderr]: ${msg}`);
if (msg.includes('I/O error') && !audioErrorLogged) {
audioErrorLogged = true;
logError(`${consoleLogTitle} Audio device "${serverConfig.audio.audioDevice}" failed to start.`);
logError('Please start the server with: node . --ffmpegdebug for more info.');
}
// Detect frozen timestamp
const match = msg.match(/time=(\d\d):(\d\d):(\d\d\.\d+)/);
if (match) {
const [_, hh, mm, ss] = match;
const totalSec = parseInt(hh) * 3600 + parseInt(mm) * 60 + parseFloat(ss);
if (lastTimestamp !== null && totalSec === lastTimestamp) {
const now = Date.now();
staleCount++;
if (staleCount >= 10 && now - lastCheckTime > 10000 && !restartTimer) {
restartTimer = setTimeout(() => {
restartTimer = null;
staleCount = 0;
try {
ffmpeg.kill('SIGKILL');
} catch (e) {
logWarn(`${consoleLogTitle} Failed to kill FFmpeg process: ${e.message}`);
}
launchFFmpeg(); // Restart FFmpeg
}, 0);
setTimeout(() => logWarn(`${consoleLogTitle} FFmpeg appears frozen. Restarting...`), 100);
}
} else {
lastTimestamp = totalSec;
lastCheckTime = Date.now();
staleCount = 0;
}
}
});
ffmpeg.on('exit', (code, signal) => {
if (signal) {
logFfmpeg(`[FFmpeg exited] with signal ${signal}`);
logWarn(`${consoleLogTitle} FFmpeg was killed with signal ${signal}`);
} else {
logFfmpeg(`[FFmpeg exited] with code ${code}`);
if (code !== 0) {
logWarn(`${consoleLogTitle} FFmpeg exited unexpectedly with code ${code}`);
}
}
// Retry on device fail
if (audioErrorLogged) {
logWarn(`${consoleLogTitle} Retrying in 10 seconds...`);
setTimeout(() => {
audioErrorLogged = false;
launchFFmpeg();
}, 10000);
}
});
}
launchFFmpeg(); // Initial launch
} else if (process.platform === 'darwin') {
// macOS (rec --> 3las.server.js --> FFmpeg)
const commandDef = buildCommand(ffmpegPath);
// Apply audio boost if enabled and FFmpeg is used
if (serverConfig.audio.audioBoost && serverConfig.audio.ffmpeg) {
commandDef.args.splice(commandDef.recArgs.indexOf('pipe:1'), 0, '-af', 'volume=2.5');
}
function startRec() {
if (!serverConfig.audio.ffmpeg) {
// Spawn rec
logDebug(`${consoleLogTitle} Launching rec with args: ${commandDef.recArgs.join(' ')}`);
//const rec = spawn(commandDef.command, { shell: true, stdio: ['ignore', 'pipe', 'pipe'] });
const rec = spawn('rec', commandDef.recArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
audioServer.waitUntilReady.then(() => {
audioServer.Server.StdIn = rec.stdout;
audioServer.Server.Run();
connectMessage(`${consoleLogTitle} Connected rec \u2192 FFmpeg \u2192 Server.StdIn${serverConfig.audio.audioBoost && serverConfig.audio.ffmpeg ? ' (audio boost)' : ''}`);
});
process.on('exit', () => {
rec.kill('SIGINT');
});
process.on('SIGINT', () => {
rec.kill('SIGINT');
process.exit();
});
rec.stderr.on('data', (data) => {
logFfmpeg(`[rec stderr]: ${data}`);
});
rec.on('exit', (code) => {
logFfmpeg(`[rec exited] with code ${code}`);
if (code !== 0) {
setTimeout(startRec, 2000);
}
});
}
}
startRec();
if (serverConfig.audio.ffmpeg) {
logDebug(`${consoleLogTitle} Launching FFmpeg with args: ${commandDef.args.join(' ')}`);
const ffmpeg = spawn(ffmpegPath, commandDef.args, { stdio: ['ignore', 'pipe', 'pipe'] });
// Pipe FFmpeg output to 3las.server.js
audioServer.waitUntilReady.then(() => {
audioServer.Server.StdIn = ffmpeg.stdout;
audioServer.Server.Run();
connectMessage(`${consoleLogTitle} Connected FFmpeg stdout \u2192 Server.StdIn${serverConfig.audio.audioBoost ? ' (audio boost)' : ''}`);
});
process.on('SIGINT', () => {
ffmpeg.kill('SIGINT');
process.exit();
});
process.on('exit', () => {
ffmpeg.kill('SIGINT');
});
// FFmpeg stderr handling
ffmpeg.stderr.on('data', (data) => {
logFfmpeg(`[FFmpeg stderr]: ${data}`);
});
// FFmpeg exit handling
ffmpeg.on('exit', (code) => {
logFfmpeg(`[FFmpeg exited] with code ${code}`);
if (code !== 0) {
logWarn(`${consoleLogTitle} FFmpeg exited unexpectedly with code ${code}`);
}
});
}
} else {
// Linux (arecord --> 3las.server.js --> FFmpeg)
const commandDef = buildCommand(ffmpegPath);
// Apply audio boost if enabled and FFmpeg is used
if (serverConfig.audio.audioBoost && serverConfig.audio.ffmpeg) {
commandDef.args.splice(commandDef.args.indexOf('pipe:1'), 0, '-af', 'volume=2.5');
}
function startArecord() {
if (!serverConfig.audio.ffmpeg) {
// Spawn the arecord loop
logDebug(`${consoleLogTitle} Launching arecord with args: ${commandDef.arecordArgs.join(' ')}`);
//const arecord = spawn(commandDef.command, { shell: true, stdio: ['ignore', 'pipe', 'pipe'] });
const arecord = spawn('arecord', commandDef.arecordArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
audioServer.waitUntilReady.then(() => {
audioServer.Server.StdIn = arecord.stdout;
audioServer.Server.Run();
connectMessage(`${consoleLogTitle} Connected arecord \u2192 FFmpeg \u2192 Server.StdIn${serverConfig.audio.audioBoost && serverConfig.audio.ffmpeg ? ' (audio boost)' : ''}`);
});
process.on('exit', () => {
arecord.kill('SIGINT');
});
process.on('SIGINT', () => {
arecord.kill('SIGINT');
process.exit();
});
arecord.stderr.on('data', (data) => {
logFfmpeg(`[arecord stderr]: ${data}`);
});
arecord.on('exit', (code) => {
logFfmpeg(`[arecord exited] with code ${code}`);
if (code !== 0) {
setTimeout(startArecord, 2000);
}
});
}
}
startArecord();
if (serverConfig.audio.ffmpeg) {
logDebug(`${consoleLogTitle} Launching FFmpeg with args: ${commandDef.args.join(' ')}`);
const ffmpeg = spawn(ffmpegPath, commandDef.args, { stdio: ['ignore', 'pipe', 'pipe'] });
// Pipe FFmpeg output to 3las.server.js
audioServer.waitUntilReady.then(() => {
audioServer.Server.StdIn = ffmpeg.stdout;
audioServer.Server.Run();
connectMessage(`${consoleLogTitle} Connected FFmpeg stdout \u2192 Server.StdIn${serverConfig.audio.audioBoost ? ' (audio boost)' : ''}`);
});
process.on('SIGINT', () => {
ffmpeg.kill('SIGINT');
process.exit();
});
process.on('exit', () => {
ffmpeg.kill('SIGINT');
});
// FFmpeg stderr handling
ffmpeg.stderr.on('data', (data) => {
logFfmpeg(`[FFmpeg stderr]: ${data}`);
});
// FFmpeg exit handling
ffmpeg.on('exit', (code) => {
logFfmpeg(`[FFmpeg exited] with code ${code}`);
if (code !== 0) {
logWarn(`${consoleLogTitle} FFmpeg exited unexpectedly with code ${code}`);
}
});
}
}
}).catch((err) => {
logError(`${consoleLogTitle} Error: ${err.message}`);
});

View File

@@ -396,4 +396,4 @@ function deg2rad(deg) {
module.exports = { module.exports = {
fetchTx fetchTx
}; };

View File

@@ -547,7 +547,7 @@
</div> </div>
</div> </div>
</div> </div>
<div id="toast-container"></div> <div id="toast-container" aria-live="polite"></div>
<script src="js/websocket.js"></script> <script src="js/websocket.js"></script>
<script src="js/webserver.js"></script> <script src="js/webserver.js"></script>
<% if (!noPlugins) { %> <% if (!noPlugins) { %>

View File

@@ -41,7 +41,7 @@ function tuneDown() {
function tuneTo(freq) { function tuneTo(freq) {
previousFreq = getCurrentFreq(); previousFreq = getCurrentFreq();
socket.send("T" + ((parseFloat(freq)) * 1000).toFixed(3)); socket.send("T" + ((parseFloat(freq)) * 1000).toFixed(0));
} }
function resetRDS() { function resetRDS() {

View File

@@ -6,6 +6,7 @@ var parsedData, signalChart, previousFreq;
var data = []; var data = [];
var signalData = []; var signalData = [];
let updateCounter = 0; let updateCounter = 0;
let lastReconnectAttempt = 0;
let messageCounter = 0; // Count for WebSocket data length returning 0 let messageCounter = 0; // Count for WebSocket data length returning 0
let messageData = 800; // Initial value anything above 0 let messageData = 800; // Initial value anything above 0
let messageLength = 800; // Retain value of messageData until value is updated let messageLength = 800; // Retain value of messageData until value is updated
@@ -375,10 +376,16 @@ function sendPingRequest() {
messageCounter = 0; messageCounter = 0;
} }
// Automatic reconnection on WebSocket close // Automatic reconnection on WebSocket close with cooldown
if (socket.readyState === WebSocket.CLOSED || socket.readyState === WebSocket.CLOSING) { const now = Date.now();
if (
(socket.readyState === WebSocket.CLOSED || socket.readyState === WebSocket.CLOSING) &&
(now - lastReconnectAttempt > TIMEOUT_DURATION)
) {
lastReconnectAttempt = now;
socket = new WebSocket(socketAddress); socket = new WebSocket(socketAddress);
socket.onopen = () => { socket.onopen = () => {
sendToast('info', 'Connected', 'Reconnected successfully!', false, false); sendToast('info', 'Connected', 'Reconnected successfully!', false, false);
}; };

View File

@@ -1,5 +1,5 @@
const versionDate = new Date('May 30, 2025 21:00:00'); const versionDate = new Date('Aug 30, 2025 21:00:00');
const currentVersion = `v1.3.9 [${versionDate.getDate()}/${versionDate.getMonth() + 1}/${versionDate.getFullYear()}]`; const currentVersion = `v1.3.10 [${versionDate.getDate()}/${versionDate.getMonth() + 1}/${versionDate.getFullYear()}]`;
function loadScript(src) { function loadScript(src) {

View File

@@ -1,30 +1,29 @@
var url = new URL('text', window.location.href); if (!window.socket || window.socket.readyState === WebSocket.CLOSED || window.socket.readyState === WebSocket.CLOSING) {
url.protocol = url.protocol.replace('http', 'ws'); var url = new URL('text', window.location.href);
var socketAddress = url.href; url.protocol = url.protocol.replace('http', 'ws');
var socket = new WebSocket(socketAddress); var socketAddress = url.href;
var socket = new WebSocket(socketAddress);
const socketPromise = new Promise((resolve, reject) => { window.socket = socket;
// Event listener for when the WebSocket connection is open
socket.addEventListener('open', () => { const socketPromise = new Promise((resolve, reject) => {
console.log('WebSocket connection open'); socket.addEventListener('open', () => {
resolve(socket); // Resolve the promise with the WebSocket instance console.log('WebSocket connection open');
resolve(socket);
});
socket.addEventListener('error', (error) => {
console.error('WebSocket error', error);
reject(error);
});
socket.addEventListener('close', () => {
setTimeout(() => {
console.warn('WebSocket connection closed');
}, 100);
reject(new Error('WebSocket connection closed'));
});
}); });
// Event listener for WebSocket errors window.socketPromise = socketPromise;
socket.addEventListener('error', (error) => { }
console.error('WebSocket error', error);
reject(error); // Reject the promise on error
});
// Event listener for WebSocket connection closure
socket.addEventListener('close', () => {
console.warn('WebSocket connection closed');
reject(new Error('WebSocket connection closed')); // Reject with closure warning
});
});
// Assign the socketPromise to window.socketPromise for global access
window.socketPromise = socketPromise;
// Assign the socket instance to window.socket for global access
window.socket = socket;

View File

@@ -39,6 +39,9 @@
<li role="tab" data-panel="users" tabindex="0"> <li role="tab" data-panel="users" tabindex="0">
<a href="#" role="tab" tabindex="-1" aria-controls="users"><i class="fa-solid fa-fw fa-user"></i> User management</a> <a href="#" role="tab" tabindex="-1" aria-controls="users"><i class="fa-solid fa-fw fa-user"></i> User management</a>
</li> </li>
<li role="tab" data-panel="startup" tabindex="0">
<a href="#" role="tab" tabindex="-1" aria-controls="startup"><i class="fa-solid fa-fw fa-plug"></i> Startup</a>
</li>
<li role="tab" data-panel="extras" tabindex="0"> <li role="tab" data-panel="extras" tabindex="0">
<a href="#" role="tab" tabindex="-1" aria-controls="extras"><i class="fa-solid fa-fw fa-star"></i> Extras</a> <a href="#" role="tab" tabindex="-1" aria-controls="extras"><i class="fa-solid fa-fw fa-star"></i> Extras</a>
</li> </li>
@@ -76,6 +79,7 @@
<div class="panel-33 p-20"> <div class="panel-33 p-20">
<span class="text-medium-big color-5"><%= memoryUsage %></span> <span class="text-medium-big color-5"><%= memoryUsage %></span>
<span class="text-small color-4" style="display: block; font-size: 1em; font-weight: 600; line-height: 0;">(<%= memoryHeap %> heap)</span>
<p>Memory usage</p> <p>Memory usage</p>
</div> </div>
@@ -217,18 +221,18 @@
</div> </div>
<div class="panel-50 p-bottom-20"> <div class="panel-50 p-bottom-20">
<h3>Experimental</h3> <h3>Experimental</h3>
<p>If you use an USB audio card on Linux, enabling this option might fix your audio issues.</p> <p>If you use a USB audio card on Linux, enabling this option might fix your audio issues.</p>
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'ALSA Software mode', id: 'audio-softwareMode'}) %> <%- include('_components', {component: 'checkbox', cssClass: '', label: 'ALSA Software mode', id: 'audio-softwareMode'}) %>
</div> </div>
</div> </div>
<div class="flex-container"> <div class="flex-container">
<div class="panel-50 p-bottom-20"> <div class="panel-50 p-bottom-20 bottom-20">
<h3>FFmpeg</h3> <h3>FFmpeg</h3>
<p>Legacy option for Linux / macOS that could resolve audio issues, but will consume additional CPU and RAM usage.</p> <p>Legacy option for Linux / macOS that could resolve audio issues, but will consume additional CPU and RAM usage.</p>
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Additional FFmpeg', id: 'audio-ffmpeg'}) %> <%- include('_components', {component: 'checkbox', cssClass: '', label: 'Additional FFmpeg', id: 'audio-ffmpeg'}) %>
</div> </div>
<div class="panel-50 p-botom-20"> <div class="panel-50 p-bottom-20 bottom-20">
<h3>Sample rate Offset</h3> <h3>Sample rate offset</h3>
<p>Using a negative value could eliminate audio buffering issues during long periods of listening. <br> <p>Using a negative value could eliminate audio buffering issues during long periods of listening. <br>
However, a value thats too low might increase the buffer over time.</p> However, a value thats too low might increase the buffer over time.</p>
<p><input class="panel-33 input-text w-100 auto" type="number" style="min-height: 40px; color: var(--color-text); padding: 10px; padding-left: 10px; box-sizing: border-box; border: 2px solid transparent; font-family: 'Titillium Web', sans-serif;" id="audio-samplerateOffset" min="-10" max="10" step="1" value="0" aria-label="Samplerate offset"></p> <p><input class="panel-33 input-text w-100 auto" type="number" style="min-height: 40px; color: var(--color-text); padding: 10px; padding-left: 10px; box-sizing: border-box; border: 2px solid transparent; font-family: 'Titillium Web', sans-serif;" id="audio-samplerateOffset" min="-10" max="10" step="1" value="0" aria-label="Samplerate offset"></p>
@@ -330,7 +334,7 @@
<%- include('_components', {component: 'text', cssClass: 'w-100', placeholder: '0', label: 'RDS Timeout', id: 'webserver-rdsTimeout'}) %> <%- include('_components', {component: 'text', cssClass: 'w-100', placeholder: '0', label: 'RDS Timeout', id: 'webserver-rdsTimeout'}) %>
</div> </div>
<div class="panel-50 p-bottom-20" style="padding-left: 20px; padding-right: 20px;"> <div class="panel-50 p-bottom-20" style="padding-left: 20px; padding-right: 20px; padding-bottom: 80px;">
<h3>Transmitter Search Algorithm</h3> <h3>Transmitter Search Algorithm</h3>
<p>Different modes may help with more accurate transmitter identification depending on your region.</p> <p>Different modes may help with more accurate transmitter identification depending on your region.</p>
<%- include('_components', { component: 'dropdown', id: 'server-tx-id-algo', inputId: 'webserver-txIdAlgorithm', label: 'Transmitter ID Algorithm', cssClass: '', placeholder: 'Algorithm 1', <%- include('_components', { component: 'dropdown', id: 'server-tx-id-algo', inputId: 'webserver-txIdAlgorithm', label: 'Transmitter ID Algorithm', cssClass: '', placeholder: 'Algorithm 1',
@@ -559,6 +563,126 @@
</div> </div>
</div> </div>
<div class="panel-full m-0 tab-content no-bg" id="startup" role="tabpanel">
<h2>Startup settings</h2>
<div class="flex-container">
<div class="panel-100-real p-bottom-20" style="z-index: 10; padding-bottom: 120px;">
<h3>On startup</h3>
<p>These settings will be applied after a server launch or restart.</p>
<div class="flex-container flex-center p-20">
<% if (device === 'tef') { %>
<%- include('_components', { component: 'dropdown', id: 'ceqStartup-dropdown', inputId: 'ceqStartup', label: 'cEQ', cssClass: '', placeholder: 'Disabled',
options: [
{ value: '0', label: 'Disabled' },
{ value: '1', label: 'Enabled' },
]
}) %><br>
<%- include('_components', { component: 'dropdown', id: 'imsStartup-dropdown', inputId: 'imsStartup', label: 'iMS', cssClass: '', placeholder: 'Disabled',
options: [
{ value: '0', label: 'Disabled' },
{ value: '1', label: 'Enabled' },
]
}) %><br>
<% } else if (device === 'xdr') { %>
<%- include('_components', { component: 'dropdown', id: 'rfStartup-dropdown', inputId: 'ceqStartup', label: 'RF+', cssClass: '', placeholder: 'Disabled',
options: [
{ value: '0', label: 'Disabled' },
{ value: '1', label: 'Enabled' },
]
}) %><br>
<%- include('_components', { component: 'dropdown', id: 'ifStartup-dropdown', inputId: 'imsStartup', label: 'IF+', cssClass: '', placeholder: 'Disabled',
options: [
{ value: '0', label: 'Disabled' },
{ value: '1', label: 'Enabled' },
]
}) %><br>
<% } %>
<%- include('_components', { component: 'dropdown', id: 'stereoStartup-dropdown', inputId: 'stereoStartup', label: 'Stereo Mode', cssClass: '', placeholder: 'Stereo (Default)',
options: [
{ value: '0', label: 'Stereo (Default)' },
{ value: '1', label: 'Mono' },
]
}) %><br>
</div>
<div class="panel-100-real p-bottom-20 no-bg">
<%- include('_components', { component: 'dropdown', id: 'antennaStartup-dropdown', inputId: 'antennaStartup', label: 'Antenna', cssClass: '', placeholder: 'Antenna 0 (Default)',
options: [
{ value: '0', label: 'Antenna 0 (Default)' },
{ value: '1', label: 'Antenna 1' },
{ value: '2', label: 'Antenna 2' },
{ value: '3', label: 'Antenna 3' },
]
}) %><br>
</div>
</div>
</div>
<div class="flex-container">
<div class="panel-100-real p-bottom-20 bottom-20" style="z-index: 9; padding-bottom: 180px;">
<h3>Empty server defaults</h3>
<p>These settings will apply once the last user disconnects from the server, so the server can be ready for a new user with default settings.</p>
<div class="flex-container flex-center p-20">
<%- include('_components', { component: 'dropdown', id: 'bwAutoNoUsers-dropdown', inputId: 'bwAutoNoUsers', label: 'Auto BW', cssClass: '', placeholder: 'Unchanged',
options: [
{ value: '0', label: 'Unchanged' },
{ value: '1', label: 'Enabled' },
]
}) %><br>
<% if (device === 'tef') { %>
<%- include('_components', { component: 'dropdown', id: 'ceqNoUsers-dropdown', inputId: 'ceqNoUsers', label: 'cEQ', cssClass: '', placeholder: 'Unchanged',
options: [
{ value: '0', label: 'Unchanged' },
{ value: '1', label: 'Disabled' },
{ value: '2', label: 'Enabled' },
]
}) %><br>
<%- include('_components', { component: 'dropdown', id: 'imsNoUsers-dropdown', inputId: 'imsNoUsers', label: 'iMS', cssClass: '', placeholder: 'Unchanged',
options: [
{ value: '0', label: 'Unchanged' },
{ value: '1', label: 'Disabled' },
{ value: '2', label: 'Enabled' },
]
}) %><br>
<% } else if (device === 'xdr') { %>
<%- include('_components', { component: 'dropdown', id: 'rfNoUsers-dropdown', inputId: 'ceqNoUsers', label: 'RF+', cssClass: '', placeholder: 'Unchanged',
options: [
{ value: '0', label: 'Unchanged' },
{ value: '1', label: 'Disabled' },
{ value: '2', label: 'Enabled' },
]
}) %><br>
<%- include('_components', { component: 'dropdown', id: 'ifNoUsers-dropdown', inputId: 'imsNoUsers', label: 'IF+', cssClass: '', placeholder: 'Unchanged',
options: [
{ value: '0', label: 'Unchanged' },
{ value: '1', label: 'Disabled' },
{ value: '2', label: 'Enabled' },
]
}) %><br>
<% } %>
<%- include('_components', { component: 'dropdown', id: 'stereoNoUsers-dropdown', inputId: 'stereoNoUsers', label: 'Stereo Mode', cssClass: '', placeholder: 'Unchanged',
options: [
{ value: '0', label: 'Unchanged' },
{ value: '1', label: 'Stereo' },
{ value: '2', label: 'Mono' },
]
}) %><br>
</div>
<div class="panel-100-real p-bottom-20 no-bg">
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Delayed Antenna Change', id: 'antennaNoUsersDelay'}) %><br>
<%- include('_components', { component: 'dropdown', id: 'antennaNoUsers-dropdown', inputId: 'antennaNoUsers', label: 'Antenna', cssClass: '', placeholder: 'Unchanged',
options: [
{ value: '0', label: 'Unchanged' },
{ value: '1', label: 'Antenna 0' },
{ value: '2', label: 'Antenna 1' },
{ value: '3', label: 'Antenna 2' },
{ value: '4', label: 'Antenna 3' },
]
}) %>
<br>
</div>
</div>
</div>
</div>
<div class="panel-full m-0 tab-content no-bg" id="extras" role="tabpanel"> <div class="panel-full m-0 tab-content no-bg" id="extras" role="tabpanel">
<h2>Extras</h2> <h2>Extras</h2>
<div class="panel-100 p-bottom-20"> <div class="panel-100 p-bottom-20">