Compare commits

...
22 Commits
Author SHA1 Message Date
kuba 0c723702f2 remove debug 2026-04-03 15:19:30 +02:00
kuba 96a78958dc debug 2026-04-03 15:18:05 +02:00
kuba a4a6dc7370 whoops 2026-04-03 15:10:40 +02:00
kuba 8f977c912d whoops 2026-04-03 15:09:33 +02:00
kuba 96fec88b52 progressive errors 2026-04-03 15:08:47 +02:00
kuba 98b48d5811 fix padding 2026-04-03 14:49:17 +02:00
kuba aa9f7a4cbd there 2026-04-03 14:48:47 +02:00
kuba 772f8c9cc1 no way 2026-04-03 14:42:23 +02:00
kuba cdd8a1008f now? 2026-04-03 14:41:39 +02:00
kuba c1d044435f change some things on the website 2026-04-03 14:38:58 +02:00
kuba b2e9342f2e minor thing, but could be important 2026-04-03 12:53:59 +02:00
kuba 4c5ab5aaef there 2026-04-03 12:51:19 +02:00
kuba 3cda783deb also update volume 2026-04-03 12:49:21 +02:00
kuba d4c13dfc28 jquery 2026-04-03 12:45:59 +02:00
kuba dab1a35abd audioonly 2026-04-03 12:44:53 +02:00
kuba ffd7b9cf8b remove old options 2026-04-03 12:33:08 +02:00
kuba 8b5b7965b4 less time 2026-04-03 12:17:29 +02:00
kuba 5968cd54c3 sync 2026-04-03 12:15:46 +02:00
kuba 537a680073 idk 2026-04-03 12:12:20 +02:00
kuba b462a0640c really? 2026-04-03 09:57:26 +02:00
kuba af9428ccf9 oh 2026-04-03 09:56:39 +02:00
kuba 28f5796ee5 something 2026-04-03 09:55:42 +02:00
22 changed files with 193 additions and 275 deletions
+1 -10
View File
@@ -1,10 +1 @@
require('./server/index.js');
/**
* FM-DX Webserver
*
* Github repo: https://github.com/KubaPro010/fm-dx-webserver
* Server files: /server
* Client files (web): /web
* Plugin files: /plugins
*/
require('./server/index.js');
+4 -6
View File
@@ -19,11 +19,6 @@ function createChatServer(storage) {
const clientIp = helpers.getIpAddress(request);
const userCommandHistory = {};
if (serverConfig.webserver.banlist?.includes(clientIp)) {
ws.close(1008, 'Banned IP');
return;
}
// Send chat history safely
storage.chatHistory.forEach((message) => {
const historyMessage = { ...message, history: true };
@@ -77,7 +72,10 @@ function createChatServer(storage) {
const now = new Date();
messageData.time = String(now.getHours()).padStart(2, '0') + ":" + String(now.getMinutes()).padStart(2, '0');
if (serverConfig.webserver.banlist?.includes(clientIp)) return;
if (serverConfig.webserver.banlist?.includes(clientIp)) {
ws.close(1008, 'Banned IP');
return;
}
if (request.session?.isAdminAuthenticated === true) messageData.admin = true;
if (messageData.nickname?.length > 32) messageData.nickname = messageData.nickname.substring(0, 32);
+31 -27
View File
@@ -7,7 +7,7 @@ const path = require('path');
const https = require('https');
// File Imports
const { parseAudioDevice } = require('./stream/parser');
const parseAudioDevice = require('./stream/parser');
const { configName, serverConfig, configUpdate, configSave, configExists, configPath } = require('./server_config');
const helpers = require('./helpers');
const storage = require('./storage');
@@ -22,9 +22,9 @@ router.get('/', (req, res) => {
let requestIp = helpers.getIpAddress(req);
const ipList = (requestIp || '').split(',').map(ip => ip.trim()).filter(Boolean); // in case there are multiple IPs (proxy), we need to check all of them (No we don't)
const banEntry = serverConfig.webserver.banlist.find(banEntry => ipList.includes(banEntry[0]));
if (banEntry) {
const reason = banEntry[3];
res.render('403', { reason });
@@ -36,14 +36,14 @@ router.get('/', (req, res) => {
if (configExists() === false) {
let serialPorts;
SerialPort.list()
.then((deviceList) => {
serialPorts = deviceList.map(port => ({
path: port.path,
friendlyName: port.friendlyName,
}));
parseAudioDevice((result) => {
res.render('wizard', { // Magical utility wizard
isAdminAuthenticated: true,
@@ -91,9 +91,13 @@ router.get('/403', (req, res) => {
res.render('403', { reason });
})
router.get('/audioonly', (req, res) => {
res.render('audioonly');
})
router.get('/wizard', (req, res) => {
let serialPorts;
if(!req.session.isAdminAuthenticated) {
res.render('login');
return;
@@ -105,7 +109,7 @@ router.get('/wizard', (req, res) => {
path: port.path,
friendlyName: port.friendlyName,
}));
parseAudioDevice((result) => {
res.render('wizard', {
isAdminAuthenticated: req.session.isAdminAuthenticated,
@@ -121,9 +125,9 @@ router.get('/wizard', (req, res) => {
});
})
})
router.get('/setup', (req, res) => {
let serialPorts;
let serialPorts;
function loadConfig() {
if (fs.existsSync(configPath)) {
const configFileContents = fs.readFileSync(configPath, 'utf8');
@@ -136,18 +140,18 @@ router.get('/setup', (req, res) => {
res.render('login');
return;
}
SerialPort.list()
.then((deviceList) => {
serialPorts = deviceList.map(port => ({
path: port.path,
friendlyName: port.friendlyName,
}));
parseAudioDevice((result) => {
const processUptimeInSeconds = Math.floor(process.uptime());
const formattedProcessUptime = helpers.formatUptime(processUptimeInSeconds);
const updatedConfig = loadConfig(); // Reload the config every time
res.render('setup', {
isAdminAuthenticated: req.session.isAdminAuthenticated,
@@ -171,9 +175,9 @@ router.get('/setup', (req, res) => {
}))
});
});
})
})
});
router.get('/rds', (req, res) => {
res.send('Please connect using a WebSocket compatible app to obtain the RDS stream.');
@@ -197,7 +201,7 @@ router.get('/api', (req, res) => {
const loginAttempts = {}; // Format: { 'ip': { count: 1, lastAttempt: 1234567890 } }
const MAX_ATTEMPTS = 10;
const WINDOW_MS = 15 * 60 * 1000;
const WINDOW_MS = 15 * 60 * 1000;
const authenticate = (req, res, next) => {
const ip = helpers.getIpAddress(req);
@@ -305,7 +309,7 @@ router.post('/saveData', (req, res) => {
if(req.session.isAdminAuthenticated || !configExists()) {
configUpdate(data);
fmdxList.update();
if(!configExists()) firstSetup = true;
logInfo('Server config changed successfully.');
if(firstSetup === true) res.status(200).send('Data saved successfully!\nPlease, restart the server to load your configuration.');
@@ -313,9 +317,9 @@ router.post('/saveData', (req, res) => {
}
});
router.get('/getData', (req, res) => {
router.get('/getData', (req, res) => {
if (configExists() === false) res.json(serverConfig);
if(req.session.isAdminAuthenticated) {
// Check if the file exists
fs.access(configPath, fs.constants.F_OK, (err) => {
@@ -360,7 +364,7 @@ router.get('/server_time', (req, res) => {
router.get('/ping', (req, res) => {
res.send('pong');
});
});
const logHistory = {};
@@ -368,27 +372,27 @@ const logHistory = {};
function canLog(id) {
const now = Date.now();
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) return false; // Deny logging if less than 60 minutes have passed
logHistory[id] = now; // Update with the current timestamp
return true;
}
router.get('/log_fmlist', (req, res) => {
if (dataHandler.dataToSend.txInfo.tx.length === 0) {
res.status(500).send('No suitable transmitter to log.');
if (serverConfig.extras.fmlistIntegration === false || (serverConfig.extras.fmlistAdminOnly && !req.session.isTuneAuthenticated)) {
res.status(500).send('FMLIST Integration is not available.');
return;
}
if (serverConfig.extras.fmlistIntegration === false || (serverConfig.extras.fmlistAdminOnly && !req.session.isTuneAuthenticated)) {
res.status(500).send('FMLIST Integration is not available.');
if (dataHandler.dataToSend.txInfo.tx.length === 0) {
res.status(500).send('No suitable transmitter to log.');
return;
}
@@ -468,7 +472,7 @@ router.get('/tunnelservers', async (req, res) => {
{ value: "sg", host: "sg.fmtuner.org", label: "Asia & Oceania" },
{ value: "pldx", host: "pldx.duckdns.org", label: "Poland (k201)" },
];
const results = await Promise.all(
servers.map(async s => {
const latency = await helpers.checkLatency(s.host);
@@ -478,7 +482,7 @@ router.get('/tunnelservers', async (req, res) => {
};
})
);
res.json(results);
});
+21 -45
View File
@@ -42,7 +42,7 @@ const plugins = findServerFiles(serverConfig.plugins);
if (plugins.length > 0) {
setTimeout(() => {
startPluginsWithDelay(plugins, 3000); // Start plugins with 3 seconds interval
}, 3000); // Initial delay of 3 seconds for the first plugin
}, 4000); // Initial delay of 4 seconds for the first plugin
}
const terminalWidth = readline.createInterface({input: process.stdin, output: process.stdout}).output.columns;
@@ -52,8 +52,7 @@ console.log('\x1b[32m\x1b[2mby Noobish @ \x1b[4mFMDX.org + KubaPro010\x1b[0m');
console.log("v" + pjson.version)
console.log('\x1b[90m' + '─'.repeat(terminalWidth - 1) + '\x1b[0m');
const audioWss = require('./stream/ws.js');
storage.websocket_delegation.set("/audio", audioWss);
require('./stream/ws.js');
require('./stream/index');
require('./plugins');
@@ -97,16 +96,13 @@ setInterval(() => {
function connectToSerial() {
if (serverConfig.xdrd.wirelessConnection === true) return;
// Configure the SerialPort with DTR and RTS options
serialport = new SerialPort({
path: serverConfig.xdrd.comPort,
baudRate: 115200,
autoOpen: false, // Prevents automatic opening
dtr: false, // Disable DTR
rts: false // Disable RTS
autoOpen: false,
dtr: false, rts: false
});
// Open the port manually after configuring DTR and RTS
serialport.open((err) => {
if (err) {
logError('Error opening port: ' + err.message);
@@ -121,7 +117,7 @@ function connectToSerial() {
pluginsApi.setOutput(serialport);
setTimeout(() => {
serialport.write('x\n');
}, 3000);
}, 2500);
setTimeout(() => {
serialport.write('Q0\n');
@@ -141,13 +137,8 @@ function connectToSerial() {
serialport.write('F-1\n');
serialport.write('W0\n');
serverConfig.webserver.rdsMode ? serialport.write('D1\n') : serialport.write('D0\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
serialport.write(`G${serverConfig.ceqStartup}${serverConfig.imsStartup}\n`);
serialport.write(`B${serverConfig.stereoStartup}\n`); // Mono
serverConfig.audio.startupVolume
? serialport.write('Y' + (serverConfig.audio.startupVolume * 100).toFixed(0) + '\n')
: serialport.write('Y100\n');
@@ -223,11 +214,10 @@ client.on('data', (data) => {
authFlags.authMsg = true;
logInfo('Authentication with xdrd successful.');
} else if (line.startsWith('G')) {
const value = line.substring(1);
dataHandler.initialData.eq = value.charAt(0);
dataHandler.dataToSend.eq = value.charAt(0);
dataHandler.initialData.ims = value.charAt(1);
dataHandler.dataToSend.ims = value.charAt(1);
dataHandler.initialData.eq = line.charAt(1);
dataHandler.dataToSend.eq = line.charAt(1);
dataHandler.initialData.ims = line.charAt(2);
dataHandler.dataToSend.ims = line.charAt(2);
} else if (line.startsWith('Z')) {
let modifiedLine = line.slice(1);
dataHandler.initialData.ant = modifiedLine;
@@ -306,13 +296,6 @@ wss.on('connection', (ws, request) => {
const output = serverConfig.xdrd.wirelessConnection ? client : serialport;
let clientIp = helpers.getIpAddress(request);
const userCommandHistory = {};
if (clientIp && serverConfig.webserver.banlist?.includes(clientIp)) {
ws.close(1008, 'Banned IP');
return;
}
if (clientIp && clientIp.includes(',')) clientIp = clientIp.split(',')[0].trim();
// Per-IP limit connection open
if (clientIp) {
@@ -336,8 +319,8 @@ wss.on('connection', (ws, request) => {
}
}
if (clientIp !== '::ffff:127.0.0.1' ||
(request.socket && request.socket.remoteAddress && request.socket.remoteAddress !== '::ffff:127.0.0.1') ||
if (clientIp !== '127.0.0.1' ||
(request.socket && request.socket.remoteAddress && request.socket.remoteAddress !== '::ffff:127.0.0.1') ||
(request.headers && request.headers['origin'] && request.headers['origin'].trim() !== '')) {
currentUsers++;
}
@@ -409,7 +392,6 @@ wss.on('connection', (ws, request) => {
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.')
@@ -420,8 +402,8 @@ wss.on('connection', (ws, request) => {
}
}
if (clientIp !== '::ffff:127.0.0.1' ||
(request.socket && request.socket.remoteAddress && request.socket.remoteAddress !== '::ffff:127.0.0.1') ||
if (clientIp !== '::ffff:127.0.0.1' ||
(request.socket && request.socket.remoteAddress && request.socket.remoteAddress !== '::ffff:127.0.0.1') ||
(request.headers && request.headers['origin'] && request.headers['origin'].trim() !== '')) {
currentUsers--;
}
@@ -490,10 +472,6 @@ wss.on('connection', (ws, request) => {
pluginsWss.on('connection', (ws, request) => {
const clientIp = helpers.getIpAddress(request);
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 };
@@ -504,7 +482,7 @@ pluginsWss.on('connection', (ws, request) => {
try {
let messageData = JSON.parse(message); // Attempt to parse the JSON
if (messageData.type === "GPS" && messageData.value) {
const gpsData = messageData.value;
const { status, time, lat, lon, alt, mode } = gpsData;
@@ -515,7 +493,7 @@ pluginsWss.on('connection', (ws, request) => {
}
}
} catch (error) {}
// Broadcast the message to all other clients
pluginsWss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) client.send(message); // Send the message to all clients
@@ -527,14 +505,13 @@ pluginsWss.on('connection', (ws, request) => {
});
});
httpServer.on('upgrade', (request, socket, head) => {
const clientIp = helpers.getIpAddress(request);
if (serverConfig.webserver.banlist?.includes(clientIp)) {
httpServer.on('upgrade', (request, socket, head) => {
if (serverConfig.webserver.banlist?.includes(helpers.getIpAddress(request))) {
socket.destroy();
return;
}
const upgradeWss = storage.websocket_delegation.get(request.url);
const upgradeWss = storage.websocket_delegation.get(request.url);
if(upgradeWss) {
sessionMiddleware(request, {}, () => {
upgradeWss.handleUpgrade(request, socket, head, (ws) => {
@@ -564,5 +541,4 @@ const startServer = (address) => {
startServer(ipv4Address);
tunnel.connect();
fmdxList.update();
module.exports = { wss, pluginsWss, httpServer, serverConfig };
fmdxList.update();
+52 -29
View File
@@ -152,11 +152,11 @@ class RDSDecoder {
clear() {
this.data.pi = '?';
this.ps = Array(8).fill(' ');
this.ps_errors = Array(8).fill("0");
this.ps_errors = Array(8).fill("10");
this.rt0 = Array(64).fill(' ');
this.rt0_errors = Array(64).fill("0");
this.rt0_errors = Array(64).fill("10");
this.rt1 = Array(64).fill(' ');
this.rt1_errors = Array(64).fill("0");
this.rt1_errors = Array(64).fill("10");
this.data.ps = '';
this.data.rt1 = '';
this.data.rt0 = '';
@@ -168,10 +168,12 @@ class RDSDecoder {
this.rt1_to_clear = false;
this.rt0_to_clear = false;
this.data.ecc = null;
this.ecc_error = 3;
this.data.country_name = ""
this.data.country_iso = "UN"
this.af_len = 0;
this.af_len_error = 3;
this.data.af = []
this.af_am_follows = false;
@@ -185,7 +187,7 @@ class RDSDecoder {
const d_error = error & 3;
if(this.last_pi_error > a_error) {
this.data.pi = blockA.toString(16).toUpperCase().padStart(4, '0');
this.data.pi = blockA.toString(16).toUpperCase().padStart(4, '0') + ("?".repeat(a_error));
this.last_pi_error = a_error;
}
@@ -209,12 +211,13 @@ class RDSDecoder {
if(af_high >= BASE && af_high <= (BASE+25)) {
this.af_len = af_high-BASE;
if(this.af_len !== this.data.af.length) {
if(this.af_len !== this.data.af.length && c_error < this.af_len_error) {
this.data.af = [];
this.af_am_follows = false;
if(af_low != FILLER && af_low != AM_FOLLOWS) this.data.af.push((af_low+875)*100)
else if(af_low == AM_FOLLOWS) this.af_am_follows = true;
this.af_len_error = c_error;
}
} else if(this.data.af.length != this.af_len) {
if(!(af_high == AM_FOLLOWS || this.af_am_follows)) {
@@ -235,11 +238,13 @@ class RDSDecoder {
const idx = blockB & 0x3;
const last_err = this.ps_errors[idx * 2];
const cur_err = Math.ceil(d_error * (10/3));
const cur_err = Math.ceil(d_error * (10/3)); // They expect an error score of 0-10 with 10 being the worst. Too bad we don't have that resolution
const character_a = decode_charset(blockD >> 8);
const character_b = decode_charset(blockD & 0xFF);
if(last_err < cur_err && this.ps[idx * 2] == character_a && this.ps[idx * 2 + 1] == character_b) return;
// TODO: Maybe add an option for toggling whether to check content?
if(last_err < cur_err) return;
// if(last_err < cur_err && this.ps[idx * 2] == character_a && this.ps[idx * 2 + 1] == character_b) return;
this.ps[idx * 2] = character_a;
this.ps[idx * 2 + 1] = character_b;
@@ -253,10 +258,12 @@ class RDSDecoder {
var variant_code = (blockC >> 12) & 0x7;
switch (variant_code) {
case 0:
if(c_error > this.ecc_error) break;
this.data.ecc = blockC & 0xff;
this.data.country_name = rdsEccLookup(blockA, this.data.ecc);
if(this.data.country_name.length === 0) this.data.country_iso = "UN";
else this.data.country_iso = iso[countries.indexOf(this.data.country_name)]
this.ecc_error = c_error
break;
default: break;
}
@@ -267,22 +274,30 @@ class RDSDecoder {
if(this.rt_ab) {
if(this.rt1_to_clear) {
this.rt1 = Array(64).fill(' ');
this.rt1_errors = Array(64).fill("0");
this.rt1_errors = Array(64).fill("10");
this.rt1_to_clear = false;
}
if(c_error < 2 && multiplier !== 2) {
this.rt1[idx * multiplier] = decode_charset(blockC >> 8);
this.rt1[idx * multiplier + 1] = decode_charset(blockC & 0xFF);
this.rt1_errors[idx * multiplier] = Math.ceil(c_error * (10/3));
this.rt1_errors[idx * multiplier + 1] = Math.ceil(c_error * (10/3));
const err = Math.ceil(c_error * (10/3));
const old_err = this.rt1_errors[idx * multiplier];
if(err < old_err) {
this.rt1[idx * multiplier] = decode_charset(blockC >> 8);
this.rt1[idx * multiplier + 1] = decode_charset(blockC & 0xFF);
this.rt1_errors[idx * multiplier] = err;
this.rt1_errors[idx * multiplier + 1] = err;
}
}
if(d_error < 2) {
var offset = (multiplier == 2) ? 0 : 2;
this.rt1[idx * multiplier + offset] = decode_charset(blockD >> 8);
this.rt1[idx * multiplier + offset + 1] = decode_charset(blockD & 0xFF);
this.rt1_errors[idx * multiplier + offset] = Math.ceil(d_error * (10/3));
this.rt1_errors[idx * multiplier + offset + 1] = Math.ceil(d_error * (10/3));
const offset = multiplier - 2;
const err = Math.ceil(d_error * (10/3));
const old_err = this.rt1_errors[idx * multiplier + offset];
if(err < old_err) {
this.rt1[idx * multiplier + offset] = decode_charset(blockD >> 8);
this.rt1[idx * multiplier + offset + 1] = decode_charset(blockD & 0xFF);
this.rt1_errors[idx * multiplier + offset] = err;
this.rt1_errors[idx * multiplier + offset + 1] = err;
}
}
var i = this.rt1.indexOf("\r")
@@ -298,22 +313,30 @@ class RDSDecoder {
} else {
if(this.rt0_to_clear) {
this.rt0 = Array(64).fill(' ');
this.rt0_errors = Array(64).fill("0");
this.rt0_errors = Array(64).fill("10");
this.rt0_to_clear = false;
}
if(c_error !== 3 && multiplier !== 2) {
this.rt0[idx * multiplier] = decode_charset(blockC >> 8);
this.rt0[idx * multiplier + 1] = decode_charset(blockC & 0xFF);
this.rt0_errors[idx * multiplier] = Math.ceil(c_error * (10/3));
this.rt0_errors[idx * multiplier + 1] = Math.ceil(c_error * (10/3));
if(c_error < 2 && multiplier !== 2) {
const err = Math.ceil(c_error * (10/3));
const old_err = this.rt0_errors[idx * multiplier];
if(err < old_err) {
this.rt0[idx * multiplier] = decode_charset(blockC >> 8);
this.rt0[idx * multiplier + 1] = decode_charset(blockC & 0xFF);
this.rt0_errors[idx * multiplier] = err;
this.rt0_errors[idx * multiplier + 1] = err;
}
}
if(d_error !== 3) {
var offset = (multiplier == 2) ? 0 : 2;
this.rt0[idx * multiplier + offset] = decode_charset(blockD >> 8);
this.rt0[idx * multiplier + offset + 1] = decode_charset(blockD & 0xFF);
this.rt0_errors[idx * multiplier + offset] = Math.ceil(d_error * (10/3));
this.rt0_errors[idx * multiplier + offset + 1] = Math.ceil(d_error * (10/3));
if(d_error < 2) {
const offset = multiplier - 2; // 2 or 0
const err = Math.ceil(d_error * (10/3));
const old_err = this.rt0_errors[idx * multiplier + offset];
if(err < old_err) {
this.rt0[idx * multiplier + offset] = decode_charset(blockD >> 8);
this.rt0[idx * multiplier + offset + 1] = decode_charset(blockD & 0xFF);
this.rt0_errors[idx * multiplier + offset] = err;
this.rt0_errors[idx * multiplier + offset + 1] = err;
}
}
var i = this.rt0.indexOf("\r");
+1 -5
View File
@@ -45,11 +45,7 @@ let serverConfig = {
audioDevice: "Microphone (High Definition Audio Device)",
audioChannels: 2,
audioBitrate: "128k",
audioBoost: false,
softwareMode: false,
startupVolume: "1",
ffmpeg: false,
samplerateOffset: "0"
startupVolume: "1"
},
identification: {
token: null,
+6 -6
View File
@@ -21,7 +21,7 @@ checkFFmpeg().then((ffmpegPath) => {
logInfo(`${consoleLogTitle} Using ${ffmpegPath === 'ffmpeg' ? 'system-installed FFmpeg' : 'ffmpeg-static'}`);
logInfo(`${consoleLogTitle} Starting audio stream on device: \x1b[35m${serverConfig.audio.audioDevice}\x1b[0m`);
const sampleRate = Number(serverConfig.audio.sampleRate || 44100) + Number(serverConfig.audio.samplerateOffset || 0); // Maybe even do 32 khz, we do not need higher than 15 khz precision
const sampleRate = Number(serverConfig.audio.sampleRate || 44100); // Maybe even do 32 khz, we do not need higher than 15 khz precision
const channels = Number(serverConfig.audio.audioChannels || 2);
@@ -41,14 +41,14 @@ checkFFmpeg().then((ffmpegPath) => {
else inputArgs = ["-f", "alsa", "-i", device];
return [
"-fflags", "+nobuffer",
"-fflags", "+nobuffer+flush_packets",
"-flags", "low_delay",
"-rtbufsize", "6144",
"-probesize", "256",
"-rtbufsize", "4096",
"-probesize", "128",
...inputArgs,
"-thread_queue_size", "2048",
"-thread_queue_size", "1024",
"-ar", String(sampleRate),
"-ac", String(channels),
@@ -62,7 +62,7 @@ checkFFmpeg().then((ffmpegPath) => {
"-id3v2_version", "0",
"-fflags", "+nobuffer",
// "-flush_packets", "1",
"-flush_packets", "1",
"pipe:1"
];
+1 -1
View File
@@ -107,4 +107,4 @@ function parseAudioDevice(options, callback) {
else return new Promise(execute);
}
module.exports = { parseAudioDevice };
module.exports = parseAudioDevice;
+2 -12
View File
@@ -1,19 +1,9 @@
const WebSocket = require('ws');
const { serverConfig } = require('../server_config');
const audio_pipe = require('./index.js');
const { getIpAddress } = require("../helpers.js")
const storage = require('../storage');
const audioWss = new WebSocket.Server({ noServer: true, skipUTF8Validation: true });
audioWss.on('connection', (ws, request) => {
const clientIp = getIpAddress(request);
if (serverConfig.webserver.banlist?.includes(clientIp)) {
ws.close(1008, 'Banned IP');
return;
}
});
audio_pipe.on('data', (chunk) => {
audioWss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) client.send(chunk, {binary: true, compress: false});
@@ -26,4 +16,4 @@ audio_pipe.on('end', () => {
});
});
module.exports = audioWss;
storage.websocket_delegation.set("/audio", audioWss);
+1 -6
View File
@@ -4,7 +4,7 @@ const consoleCmd = require('./console');
let localDb = {};
let nextLocalDbUpdate = 0;
const localDbUpdateInterval = 7 * 24 * 60 * 60 * 1000; // 7-day database update interval
const localDbUpdateInterval = 3 * 24 * 60 * 60 * 1000; // 3-day database update interval
let awaitingTxInfo = true;
let lastFetchTime = 0;
let piFreqIndex = {}; // Indexing for speedier PI+Freq combinations
@@ -18,11 +18,6 @@ let usStatesGeoJson = null; // To cache the GeoJSON data for US states
let Latitude = serverConfig.identification.lat;
let Longitude = serverConfig.identification.lon;
// Create WebSocket URL for GPS lat/lon update.
const webserverPort = serverConfig.webserver.webserverPort || 8080; // Fallback to port 8080
const externalWsUrl = `ws://127.0.0.1:${webserverPort}/data_plugins`;
const WebSocket = require('ws');
// Get weighting values based on algorithm setting.
// Defaults = algorithm 1
let weightedErp = 10;
+1 -3
View File
@@ -20,9 +20,7 @@
<div class="panel-100 p-10">
<br>
<i class="text-big fa-solid fa-exclamation-triangle color-4"></i>
<p>
Service refused..<br>
Please try again later.</p>
<p>Service refused, please try again later.</p>
<% if (reason) { %>
<p><strong>Reason:</strong> <%= reason %></p>
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<title>FM-DX Webserver (Audio)</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg" id="favicon" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="css/libs/jquery-ui.min.css" type="text/css" rel="stylesheet">
<script src="js/libs/jquery.min.js"></script>
<script src="js/libs/jquery-ui.min.js"></script>
<script src="js/audio.js"></script>
</head>
<body>
<button class="playbutton">Start / Stop</button>
<input type="range" id="volumeSlider" min="0" max="1" step="0.01" value="1">
</body>
</html>
+2 -11
View File
@@ -1,13 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<title><%= tunerName %> - FM-DX Webserver</title>
<link href="css/entry.css" type="text/css" rel="stylesheet">
<link href="css/flags.min.css" type="text/css" rel="stylesheet">
<link href="css/libs/fontawesome.css" type="text/css" rel="stylesheet">
<link href="css/libs/jquery-ui.min.css" type="text/css" rel="stylesheet">
<!--<link href="css/libs/jquery-ui.theme.min.css" type="text/css" rel="stylesheet">-->
<script src="js/libs/jquery.min.js"></script>
<script src="js/libs/jquery-ui.min.js"></script>
<script src="js/libs/chart.umd.min.js"></script>
@@ -26,7 +24,6 @@
<script src="js/audio.js"></script>
<script src="js/3las_helper.js"></script>
<script type="text/javascript">
window.addEventListener('load', Init, false);
document.ontouchmove = function(e) {
e.preventDefault();
}
@@ -454,7 +451,7 @@
<div id="myModal" class="modal">
<div class="modal-panel">
<div class="flex-container flex-phone" style="height: calc(100% - 100px)">
<div class="flex-container flex-phone" style="height: calc(100% - 50px)">
<div class="modal-panel-sidebar hover-brighten flex-center text-medium-big closeModal" role="button" aria-label="Close settings" tabindex="0"><i class="fa-solid fa-chevron-right"></i></div>
<div class="modal-panel-content">
<h1 class="top-25">Settings</h1>
@@ -480,7 +477,7 @@
<%- include('_components', { component: 'dropdown', id: 'signal-selector', inputId: 'signal-selector-input', label: 'Signal units', cssClass: '', placeholder: 'dBf',
options: [
{ value: 'dbf', label: 'dBf' },
{ value: 'dbuv', label: 'dBuV' },
{ value: 'dbuv', label: 'dBμV' },
{ value: 'dbm', label: 'dBm' },
]
}) %>
@@ -542,17 +539,11 @@
</div>
<div class="modal-panel-footer flex-container flex-phone">
<div class="modal-panel-sidebar" style="font-size: 22px;">
<div class="flex-center" style="height: 50px">
<i class="fa-solid fa-hand-holding-medical"></i>
</div>
<div class="flex-center" style="height: 50px">
<i class="fa-brands fa-discord"></i>
</div>
</div>
<div class="modal-panel-content">
<div class="hover-brighten br-0 bg-color-1" style="height: 50px;padding:12px;" onclick="window.open('https://buymeacoffee.com/noobish')">
<strong>Support</strong> the developer!
</div>
<div class="hover-brighten br-0 bg-color-1" style="height: 50px;padding:12px;" onclick="window.open('https://discord.gg/cY66nt6UhV')">
Join our <strong>FMDX.org Discord</strong> community!
</div>
+4 -32
View File
@@ -213,33 +213,7 @@
]
}) %><br>
</div>
</div>
<div class="flex-container">
<div class="panel-50 p-bottom-20">
<h3>Audio boost</h3>
<p>This option will boost the audio volume. Use if the output is too quiet.</p>
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Audio Boost', id: 'audio-audioBoost'}) %>
</div>
<div class="panel-50 p-bottom-20">
<h3>Experimental</h3>
<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'}) %>
</div>
</div>
<div class="flex-container">
<div class="panel-50 p-bottom-20 bottom-20">
<h3>FFmpeg</h3>
<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'}) %>
</div>
<div class="panel-50 p-bottom-20 bottom-20">
<h3>Sample rate offset</h3>
<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>
<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>
</div>
</div>
</div>
<div class="panel-full m-0 tab-content no-bg" id="webserver" role="tabpanel">
@@ -429,12 +403,10 @@
<p>Toggling this option will put the tuner to sleep when no clients are connected.</p>
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Auto-shutdown', id: 'autoShutdown'}) %><br>
</div>
<div class="flex-container">
<div class="panel-50 no-bg">
<h4>SI47XX AGC control</h4>
<p>Allow users to change SI47XX AGC mode from the main UI.</p>
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Enable AGC control', id: 'si47xx-agcControl'}) %><br>
</div>
<div class="panel-50 no-bg">
<h4>SI47XX AGC control</h4>
<p>Allow users to change SI47XX AGC mode from the main UI.</p>
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Enable AGC control', id: 'si47xx-agcControl'}) %><br>
</div>
</div>
</div>
-13
View File
@@ -142,19 +142,6 @@
]
}) %>
</div>
<div class="panel-100 no-bg text-center">
<div class="flex-container">
<div class="panel-50 no-bg">
<p>If you use an USB audio card on Linux, enabling this option might fix your audio issues.</p>
<%- include('_components', {component: 'checkbox', cssClass: 'panel-100 flex-container flex-center', label: 'ALSA Software mode', id: 'audio-softwareMode'}) %>
</div>
<div class="panel-50 no-bg">
<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: 'panel-100 flex-container flex-center', label: 'Additional FFmpeg', id: 'audio-ffmpeg'}) %>
</div>
</div>
</div>
</div>
</div>
<!-- AUDIO SETTINGS END -->
+19 -45
View File
@@ -11,18 +11,12 @@ var isBSD;
var isMacOSX;
var isInternetExplorer;
var isEdge;
;
var isSafari;
;
var isOpera;
;
var isChrome;
;
var isFirefox;
;
var webkitVer;
var isNativeChrome;
;
var BrowserName;
var OSName;
{
@@ -43,39 +37,23 @@ var OSName;
webkitVer = parseInt((/WebKit\/([0-9]+)/.exec(navigator.appVersion) || ["", "0"])[1], 10) || void 0; // also match AppleWebKit
isNativeChrome = isAndroid && webkitVer <= 537 && navigator.vendor.toLowerCase().indexOf('google') == 0;
BrowserName = "Unknown";
if (isInternetExplorer)
BrowserName = "IE";
else if (isEdge)
BrowserName = "Edge";
else if (isSafari)
BrowserName = "Safari";
else if (isOpera)
BrowserName = "Opera";
else if (isChrome)
BrowserName = "Chrome";
else if (isFirefox)
BrowserName = "Firefox";
else if (isNativeChrome)
BrowserName = "NativeChrome";
else
BrowserName = "Unknown";
if (isInternetExplorer) BrowserName = "IE";
else if (isEdge) BrowserName = "Edge";
else if (isSafari) BrowserName = "Safari";
else if (isOpera) BrowserName = "Opera";
else if (isChrome) BrowserName = "Chrome";
else if (isFirefox) BrowserName = "Firefox";
else if (isNativeChrome) BrowserName = "NativeChrome";
else BrowserName = "Unknown";
OSName = "Unknown";
if (isAndroid)
OSName = "Android";
else if (isIOS)
OSName = "iOS";
else if (isIPadOS)
OSName = "iPadOS";
else if (isWindows)
OSName = "Windows";
else if (isLinux)
OSName = "Linux";
else if (isBSD)
OSName = "BSD";
else if (isMacOSX)
OSName = "MacOSX";
else
OSName = "Unknown";
if (isAndroid) OSName = "Android";
else if (isIOS) OSName = "iOS";
else if (isIPadOS) OSName = "iPadOS";
else if (isWindows) OSName = "Windows";
else if (isLinux) OSName = "Linux";
else if (isBSD) OSName = "BSD";
else if (isMacOSX) OSName = "MacOSX";
else OSName = "Unknown";
}
;
var WakeLock = /** @class */ (function () {
@@ -91,8 +69,7 @@ var WakeLock = /** @class */ (function () {
WakeLock.AddSourceToVideo(video, 'mp4', 'data:video/mp4;base64,' + WakeLock.VideoMp4);
document.body.appendChild(video);
this.LockElement = video;
}
else {
} else {
this.Logger.Log("Using WakeLock API.");
this.LockElement = null;
}
@@ -109,8 +86,7 @@ var WakeLock = /** @class */ (function () {
_this.Logger.Log("WakeLock request failed.");
console.log("WakeLock request failed.");
});
}
catch (err) {
} catch (err) {
this.Logger.Log("WakeLock request failed.");
console.log("WakeLock request failed.");
}
@@ -123,9 +99,7 @@ var WakeLock = /** @class */ (function () {
_this.LockElement.play().catch(err => {
console.error("LockElement failed:", err);
});
} else {
console.warn("LockElement not a media element or already assigned.");
}
} else console.warn("LockElement not a media element or already assigned.");
}
};
WakeLock.AddSourceToVideo = function (element, type, dataURI) {
+7 -9
View File
@@ -1,12 +1,12 @@
function tuneUp() {
if (socket.readyState === WebSocket.OPEN) {
getCurrentFreq();
let addVal = 0;
if (currentFreq < 0.52) addVal = 9 - (Math.round(currentFreq*1000) % 9);
else if (currentFreq < 1.71) {
// TODO: Rework to replace 9 with 9 or 10 based on regionalisation setting
addVal = 9 - (Math.round(currentFreq*1000) % 9);
var step = 9;
if(localStorage.getItem('rdsMode') == 'true') step = 10
addVal = step - (Math.round(currentFreq*1000) % step);
} else if (currentFreq < 29.6) addVal = 5 - (Math.round(currentFreq*1000) % 5);
else if (currentFreq >= 65.9 && currentFreq < 74) addVal = 30 - ((Math.round(currentFreq*1000) - 65900) % 30);
else addVal = 100 - (Math.round(currentFreq*1000) % 100);
@@ -20,8 +20,9 @@ function tuneDown() {
let subVal = 0;
if (currentFreq < 0.52) subVal = (Math.round(currentFreq*1000) % 9 == 0) ? 9 : (Math.round(currentFreq*1000) % 9);
else if (currentFreq < 1.71) {
// TODO: Rework to replace 9 with 9 or 10 based on regionalisation setting (Americans use 10, because of dumbfuckinstan)
subVal = (Math.round(currentFreq*1000) % 9 == 0) ? 9 : (Math.round(currentFreq*1000) % 9);
var step = 9;
if(localStorage.getItem('rdsMode') == 'true') step = 10
subVal = (Math.round(currentFreq*1000) % step == 0) ? step : (Math.round(currentFreq*1000) % step);
} else if (currentFreq < 29.6) subVal = (Math.round(currentFreq*1000) % 5 == 0) ? 5 : (Math.round(currentFreq*1000) % 5);
else if (currentFreq > 65.9 && currentFreq <= 74) subVal = ((Math.round(currentFreq*1000) - 65900) % 30 == 0) ? 30 : ((Math.round(currentFreq*1000) - 65900) % 30);
else subVal = (Math.round(currentFreq*1000) % 100 == 0) ? 100 : (Math.round(currentFreq*1000) % 100);
@@ -39,9 +40,6 @@ function resetRDS() {
}
function getCurrentFreq() {
currentFreq = $('#data-frequency').text();
currentFreq = parseFloat(currentFreq).toFixed(3);
currentFreq = parseFloat(currentFreq);
currentFreq = Math.round(parseFloat($('#data-frequency').text()) * 1000) / 1000;
return currentFreq;
}
+21 -7
View File
@@ -9,6 +9,17 @@ class WebSocketAudioPlayer {
this.started = false;
}
_syncToLive() {
if (!this.audio || !this.sourceBuffer) return;
const buffered = this.sourceBuffer.buffered;
if (buffered.length === 0) return;
const liveEdge = buffered.end(buffered.length - 1);
const lag = liveEdge - this.audio.currentTime;
if (lag > .5) this.audio.currentTime = liveEdge - 0.1; // snap to near live edge
}
start() {
this.audio = new Audio();
this.mediaSource = new MediaSource();
@@ -17,10 +28,11 @@ class WebSocketAudioPlayer {
this.mediaSource.addEventListener('sourceopen', () => {
this.sourceBuffer = this.mediaSource.addSourceBuffer('audio/mpeg');
this.sourceBuffer.addEventListener('updateend', () => this._appendNext());
this.audio.addEventListener('waiting', () => this._syncToLive());
this.audio.addEventListener('stalled', () => this._syncToLive());
this.ws = new WebSocket(this.url);
this.ws.binaryType = 'arraybuffer';
this.ws.onopen = () => this.ws.send(JSON.stringify({ type: 'fallback', data: 'mp3' }));
this.ws.onmessage = (event) => {
this.queue.push(event.data);
this._appendNext();
@@ -50,6 +62,7 @@ class WebSocketAudioPlayer {
_appendNext() {
if (this.sourceBuffer.updating || this.queue.length === 0) return;
this.sourceBuffer.appendBuffer(this.queue.shift());
this._syncToLive();
}
}
@@ -64,8 +77,7 @@ function Init(_ev) {
function createStream() {
try {
const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const audioUrl = `${wsProtocol}//${location.hostname}/audio`;
Stream = new WebSocketAudioPlayer(audioUrl);
Stream = new WebSocketAudioPlayer(`${wsProtocol}//${location.hostname}/audio`);
Stream.setVolume($('#volumeSlider').val());
} catch (error) {
console.error("Initialization Error: ", error);
@@ -87,19 +99,20 @@ function OnPlayButtonClick(_ev) {
console.log("Stopping stream...");
shouldReconnect = false;
destroyStream();
$playbutton.find('.fa-solid').toggleClass('fa-stop fa-play');
$playbutton.find('.fa-solid')?.toggleClass('fa-stop fa-play');
if (isAppleiOS && 'audioSession' in navigator) navigator.audioSession.type = "none";
} else {
console.log("Starting stream...");
shouldReconnect = true;
createStream();
Stream.start();
$playbutton.find('.fa-solid').toggleClass('fa-play fa-stop');
$playbutton.find('.fa-solid')?.toggleClass('fa-play fa-stop');
if (isAppleiOS && 'audioSession' in navigator) navigator.audioSession.type = "playback";
}
$playbutton.addClass('bg-gray').prop('disabled', true);
setTimeout(() => $playbutton.removeClass('bg-gray').prop('disabled', false), 3000);
setTimeout(() => $playbutton.removeClass('bg-gray').prop('disabled', false), 2500);
if (Stream) Stream.setVolume($("#volumeSlider").val());
}
function updateVolume() {
@@ -108,4 +121,5 @@ function updateVolume() {
else console.warn("Stream is not initialized.");
}
$(document).ready(Init);
$(document).ready(Init);
window.addEventListener('load', Init, false);
-4
View File
@@ -1,7 +1,3 @@
// WebSocket connection located in ./websocket.js
var parsedData, signalChart, previousFreq;
var data = [];
var signalData = [];
-2
View File
@@ -19,8 +19,6 @@ function checkScroll() {
}
}
$(document).ready(function () {
let $container = $(".scrollable-container");
let $leftArrow = $(".scroll-left");
+1 -1
View File
@@ -1,2 +1,2 @@
const versionDate = new Date('Feb 24, 2026 15:00:00');
const versionDate = new Date('Apr 3, 2026 9:30:00');
const currentVersion = `v1.4.0a [${versionDate.getDate()}/${versionDate.getMonth() + 1}/${versionDate.getFullYear()}]`;
+1 -1
View File
@@ -16,7 +16,7 @@ function updateWizardContent() {
$('.btn-prev').toggle(visibleStepIndex !== 0);
$('.btn-next').text(visibleStepIndex === 4 ? 'Save' : 'Next');
visibleStepIndex === 3 && mapReload();
visibleStepIndex === 3 && mapReload(); // What is this? Javascript? Toy?
}
function navigateStep(isNext) {