Compare commits

..
4 Commits
Author SHA1 Message Date
kuba 34363b6c3b update readme 2025-12-16 21:46:26 +01:00
kuba f87f3c8eaa remove workflow 2025-12-16 21:37:26 +01:00
kuba d3ab634846 koffi is not needed 2025-12-16 21:36:12 +01:00
kuba ddff8f51e5 why did we even use an c dynamic link in node js 2025-12-16 21:32:26 +01:00
14 changed files with 1736 additions and 566 deletions
-61
View File
@@ -1,61 +0,0 @@
name: Fetch and Commit librdsparser
on:
workflow_dispatch:
inputs:
version:
description: 'Release version'
required: true
default: 'v1.1'
jobs:
fetch-and-commit:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Download all librdsparser release assets
run: |
set -e
cd server/libraries
VERSION="${{ github.event.inputs.version }}"
echo "Fetching librdsparser release: $VERSION"
platforms=(
"aarch64"
"arm"
"macos"
"mingw32"
"mingw64"
"x86"
"x86_64"
)
base_url="https://github.com/kkonradpl/librdsparser/releases/download/$VERSION"
for platform in "${platforms[@]}"; do
filename="librdsparser-${platform}.zip"
dir="${platform}"
mkdir -p "$dir"
echo "Downloading $filename..."
curl -sSL -o "$dir/$filename" "$base_url/$filename"
echo "Extracting from $filename..."
unzip -j "$dir/$filename" "*.dll" "*.so" "*.dylib" -d "$dir" || echo "No binaries in $filename"
rm "$dir/$filename"
done
- name: Commit and push binaries to the repo
run: |
git config user.name "github-actions"
git config user.email "github-actions@github.com"
git add server/libraries
git diff --cached --quiet || git commit -m "Add librdsparser ${{ github.event.inputs.version }}"
git push
+1 -1
View File
@@ -2,7 +2,7 @@ node_modules/
/*.json /*.json
/serverlog.txt /serverlog.txt
/web/js/plugins/ /web/js/plugins/
/libraries/ /libraries/**
/plugins/* /plugins/*
!/plugins/example/frontend.js !/plugins/example/frontend.js
!/plugins/example.js !/plugins/example.js
-1
View File
@@ -33,7 +33,6 @@ This project utilizes these libraries:
- [3LAS](https://github.com/jojobond/3LAS) library by JoJoBond for Low Latency Audio Streaming. - [3LAS](https://github.com/jojobond/3LAS) library by JoJoBond for Low Latency Audio Streaming.
- [flat-flags](https://github.com/luishdez/flat-flags) library by luishdez for RDS country flags. - [flat-flags](https://github.com/luishdez/flat-flags) library by luishdez for RDS country flags.
- [librdsparser](https://github.com/kkonradpl/librdsparser) library by Konrad Kosmatka for RDS parsing.
All of these libraries are already bundled with the webserver. All of these libraries are already bundled with the webserver.
+40 -7
View File
@@ -16,7 +16,6 @@
"express-session": "1.18.2", "express-session": "1.18.2",
"ffmpeg-static": "5.2.0", "ffmpeg-static": "5.2.0",
"http": "0.0.1-security", "http": "0.0.1-security",
"koffi": "2.7.2",
"net": "1.0.2", "net": "1.0.2",
"serialport": "12.0.0", "serialport": "12.0.0",
"ws": "8.18.1" "ws": "8.18.1"
@@ -516,6 +515,20 @@
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
}, },
"node_modules/bufferutil": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.8.tgz",
"integrity": "sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==",
"hasInstallScript": true,
"optional": true,
"peer": true,
"dependencies": {
"node-gyp-build": "^4.3.0"
},
"engines": {
"node": ">=6.14.2"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1181,12 +1194,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/koffi": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/koffi/-/koffi-2.7.2.tgz",
"integrity": "sha512-AWcsEKETQuELxK0Wq/aXDkDiNFFY41TxZQSrKm2Nd6HO/KTHeohPOOIlh2OfQnBXJbRjx5etpWt8cbqMUZo2sg==",
"hasInstallScript": true
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -1339,6 +1346,18 @@
} }
} }
}, },
"node_modules/node-gyp-build": {
"version": "4.6.1",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.6.1.tgz",
"integrity": "sha512-24vnklJmyRS8ViBNI8KbtK/r/DmXQMRiOMXTNz2nrTnAYUwjmEEbnnpB/+kt+yWRv73bPsSPRFddrcIbAxSiMQ==",
"optional": true,
"peer": true,
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/nopt": { "node_modules/nopt": {
"version": "8.1.0", "version": "8.1.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz",
@@ -1865,6 +1884,20 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/utf-8-validate": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz",
"integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==",
"hasInstallScript": true,
"optional": true,
"peer": true,
"dependencies": {
"node-gyp-build": "^4.3.0"
},
"engines": {
"node": ">=6.14.2"
}
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
-1
View File
@@ -19,7 +19,6 @@
"express-session": "1.18.2", "express-session": "1.18.2",
"ffmpeg-static": "5.2.0", "ffmpeg-static": "5.2.0",
"http": "0.0.1-security", "http": "0.0.1-security",
"koffi": "2.7.2",
"net": "1.0.2", "net": "1.0.2",
"serialport": "12.0.0", "serialport": "12.0.0",
"ws": "8.18.1" "ws": "8.18.1"
-121
View File
@@ -1,121 +0,0 @@
const WebSocket = require('ws');
const { serverConfig } = require('./server_config');
const { logChat } = require('./console');
const helpers = require('./helpers');
function createChatServer(storage) {
if (!serverConfig.webserver.chatEnabled) {
return null;
}
const chatWss = new WebSocket.Server({ noServer: true });
chatWss.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;
}
// Send chat history safely
storage.chatHistory.forEach((message) => {
const historyMessage = { ...message, history: true };
if (!request.session?.isAdminAuthenticated) {
delete historyMessage.ip;
}
ws.send(JSON.stringify(historyMessage));
});
const ipMessage = {
type: 'clientIp',
ip: clientIp,
admin: request.session?.isAdminAuthenticated
};
ws.send(JSON.stringify(ipMessage));
const userCommands = {};
let lastWarn = { time: 0 };
ws.on('message', (message) => {
helpers.antispamProtection(
message,
clientIp,
ws,
userCommands,
lastWarn,
userCommandHistory,
'5',
'chat'
);
let messageData;
try {
messageData = JSON.parse(message);
} catch {
ws.send(JSON.stringify({ error: "Invalid message format" }));
return;
}
console.log("Chat message:", messageData);
delete messageData.admin;
delete messageData.ip;
delete messageData.time;
if (messageData.nickname != null) {
messageData.nickname = helpers.escapeHtml(String(messageData.nickname));
}
messageData.ip = clientIp;
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 (request.session?.isAdminAuthenticated === true) {
messageData.admin = true;
}
if (messageData.nickname?.length > 32) {
messageData.nickname = messageData.nickname.substring(0, 32);
}
if (messageData.message?.length > 255) {
messageData.message = messageData.message.substring(0, 255);
}
storage.chatHistory.push(messageData);
if (storage.chatHistory.length > 50) {
storage.chatHistory.shift();
}
logChat(messageData);
chatWss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
const responseMessage = { ...messageData };
if (!request.session?.isAdminAuthenticated) {
delete responseMessage.ip;
}
client.send(JSON.stringify(responseMessage));
}
});
});
});
return chatWss; // ← VERY IMPORTANT
}
module.exports = { createChatServer };
+16 -211
View File
@@ -1,213 +1,7 @@
/* Libraries / Imports */ /* Libraries / Imports */
const fs = require('fs');
const https = require('https');
const koffi = require('koffi');
const path = require('path');
const os = require('os');
const platform = os.platform();
const cpuArchitecture = os.arch();
const { configName, serverConfig, configUpdate, configSave } = require('./server_config'); const { configName, serverConfig, configUpdate, configSave } = require('./server_config');
let unicode_type; const RDSDecoder = require("./rds.js");
let shared_Library;
if (platform === 'win32') {
unicode_type = 'int16_t';
arch_type = (cpuArchitecture === 'x64' ? 'mingw64' : 'mingw32');
shared_Library=path.join(__dirname, "libraries", arch_type, "librdsparser.dll");
} else if (platform === 'linux') {
unicode_type = 'int32_t';
arch_type = (cpuArchitecture === 'x64' ? 'x86_64' :
(cpuArchitecture === 'ia32' ? 'x86' :
(cpuArchitecture === 'arm64' ? 'aarch64' : cpuArchitecture)));
shared_Library=path.join(__dirname, "libraries", arch_type, "librdsparser.so");
} else if (platform === 'darwin') {
unicode_type = 'int32_t';
shared_Library=path.join(__dirname, "libraries", "macos", "librdsparser.dylib");
}
const lib = koffi.load(shared_Library);
const { fetchTx } = require('./tx_search.js'); const { fetchTx } = require('./tx_search.js');
koffi.proto('void callback_pi(void *rds, void *user_data)');
koffi.proto('void callback_pty(void *rds, void *user_data)');
koffi.proto('void callback_tp(void *rds, void *user_data)');
koffi.proto('void callback_ta(void *rds, void *user_data)');
koffi.proto('void callback_ms(void *rds, void *user_data)');
koffi.proto('void callback_ecc(void *rds, void *user_data)');
koffi.proto('void callback_country(void *rds, void *user_data)');
koffi.proto('void callback_af(void *rds, uint32_t af, void *user_data)');
koffi.proto('void callback_ps(void *rds, void *user_data)');
koffi.proto('void callback_rt(void *rds, int flag, void *user_data)');
koffi.proto('void callback_ptyn(void *rds, void *user_data)');
koffi.proto('void callback_ct(void *rds, void *ct, void *user_data)');
const rdsparser = {
new: lib.func('void* rdsparser_new()'),
free: lib.func('void rdsparser_free(void *rds)'),
clear: lib.func('void rdsparser_clear(void *rds)'),
parse_string: lib.func('bool rdsparser_parse_string(void *rds, const char *input)'),
set_text_correction: lib.func('void rdsparser_set_text_correction(void *rds, uint8_t text, uint8_t type, uint8_t error)'),
set_text_progressive: lib.func('void rdsparser_set_text_progressive(void *rds, uint8_t string, uint8_t state)'),
get_pi: lib.func('int32_t rdsparser_get_pi(void *rds)'),
get_pty: lib.func('int8_t rdsparser_get_pty(void *rds)'),
get_tp: lib.func('int8_t rdsparser_get_tp(void *rds)'),
get_ta: lib.func('int8_t rdsparser_get_ta(void *rds)'),
get_ms: lib.func('int8_t rdsparser_get_ms(void *rds)'),
get_ecc: lib.func('int16_t rdsparser_get_ecc(void *rds)'),
get_country: lib.func('int rdsparser_get_country(void *rds)'),
get_ps: lib.func('void* rdsparser_get_ps(void *rds)'),
get_rt: lib.func('void* rdsparser_get_rt(void *rds, int flag)'),
get_ptyn: lib.func('void* rdsparser_get_ptyn(void *rds)'),
register_pi: lib.func('void rdsparser_register_pi(void *rds, void *cb)'),
register_pty: lib.func('void rdsparser_register_pty(void *rds, void *cb)'),
register_tp: lib.func('void rdsparser_register_tp(void *rds, void *cb)'),
register_ta: lib.func('void rdsparser_register_ta(void *rds, void *cb)'),
register_ms: lib.func('void rdsparser_register_ms(void *rds, void *cb)'),
register_ecc: lib.func('void rdsparser_register_ecc(void *rds, void *cb)'),
register_country: lib.func('void rdsparser_register_country(void *rds, void *cb)'),
register_af: lib.func('void rdsparser_register_af(void *rds, void *cb)'),
register_ps: lib.func('void rdsparser_register_ps(void *rds, void *cb)'),
register_rt: lib.func('void rdsparser_register_rt(void *rds, void *cb)'),
register_ptyn: lib.func('void rdsparser_register_ptyn(void *rds, void *cb)'),
register_ct: lib.func('void rdsparser_register_ct(void *rds, void *cb)'),
string_get_content: lib.func(unicode_type + '* rdsparser_string_get_content(void *string)'),
string_get_errors: lib.func('uint8_t* rdsparser_string_get_errors(void *string)'),
string_get_length: lib.func('uint8_t rdsparser_string_get_length(void *string)'),
ct_get_year: lib.func('uint16_t rdsparser_ct_get_year(void *ct)'),
ct_get_month: lib.func('uint8_t rdsparser_ct_get_month(void *ct)'),
ct_get_day: lib.func('uint8_t rdsparser_ct_get_day(void *ct)'),
ct_get_hour: lib.func('uint8_t rdsparser_ct_get_hour(void *ct)'),
ct_get_minute: lib.func('uint8_t rdsparser_ct_get_minute(void *ct)'),
ct_get_offset: lib.func('int8_t rdsparser_ct_get_offset(void *ct)'),
pty_lookup_short: lib.func('const char* rdsparser_pty_lookup_short(int8_t pty, bool rbds)'),
pty_lookup_long: lib.func('const char* rdsparser_pty_lookup_long(int8_t pty, bool rbds)'),
country_lookup_name: lib.func('const char* rdsparser_country_lookup_name(int country)'),
country_lookup_iso: lib.func('const char* rdsparser_country_lookup_iso(int country)')
}
const callbacks = {
pi: koffi.register(rds => (
value = rdsparser.get_pi(rds)
//console.log('PI: ' + value.toString(16).toUpperCase())
), 'callback_pi*'),
pty: koffi.register(rds => (
value = rdsparser.get_pty(rds),
dataToSend.pty = value
), 'callback_pty*'),
tp: koffi.register(rds => (
value = rdsparser.get_tp(rds),
dataToSend.tp = value
), 'callback_tp*'),
ta: koffi.register(rds => (
value = rdsparser.get_ta(rds),
dataToSend.ta = value
), 'callback_ta*'),
ms: koffi.register(rds => (
value = rdsparser.get_ms(rds),
dataToSend.ms = value
), 'callback_ms*'),
af: koffi.register((rds, value) => (
dataToSend.af.push(value)
), 'callback_af*'),
ecc: koffi.register(rds => (
value = rdsparser.get_ecc(rds),
dataToSend.ecc = value
), 'callback_ecc*'),
country: koffi.register(rds => (
value = rdsparser.get_country(rds),
display = rdsparser.country_lookup_name(value),
iso = rdsparser.country_lookup_iso(value),
dataToSend.country_name = display,
dataToSend.country_iso = iso
), 'callback_country*'),
ps: koffi.register(rds => (
ps = rdsparser.get_ps(rds),
dataToSend.ps = decode_unicode(ps),
dataToSend.ps_errors = decode_errors(ps)
), 'callback_ps*'),
rt: koffi.register((rds, flag) => {
const rt = rdsparser.get_rt(rds, flag);
if (flag === 0) {
dataToSend.rt0 = decode_unicode(rt);
dataToSend.rt0_errors = decode_errors(rt);
}
if (flag === 1) {
dataToSend.rt1 = decode_unicode(rt);
dataToSend.rt1_errors = decode_errors(rt);
}
dataToSend.rt_flag = flag;
}, 'callback_rt*'),
ptyn: koffi.register((rds, flag) => (
value = decode_unicode(rdsparser.get_ptyn(rds))
/*console.log('PTYN: ' + value)*/
), 'callback_ptyn*'),
ct: koffi.register((rds, ct) => (
year = rdsparser.ct_get_year(ct),
month = String(rdsparser.ct_get_month(ct)).padStart(2, '0'),
day = String(rdsparser.ct_get_day(ct)).padStart(2, '0'),
hour = String(rdsparser.ct_get_hour(ct)).padStart(2, '0'),
minute = String(rdsparser.ct_get_minute(ct)).padStart(2, '0'),
offset = rdsparser.ct_get_offset(ct),
tz_sign = (offset >= 0 ? '+' : '-'),
tz_hour = String(Math.abs(Math.floor(offset / 60))).padStart(2, '0'),
tz_minute = String(Math.abs(offset % 60)).padStart(2, '0')
//console.log('CT: ' + year + '-' + month + '-' + day + ' ' + hour + ':' + minute + ' (' + tz_sign + tz_hour + ':' + tz_minute + ')')
), 'callback_ct*')
};
let rds = rdsparser.new()
rdsparser.set_text_correction(rds, 0, 0, 2);
rdsparser.set_text_correction(rds, 0, 1, 2);
rdsparser.set_text_correction(rds, 1, 0, 2);
rdsparser.set_text_correction(rds, 1, 1, 2);
rdsparser.set_text_progressive(rds, 0, 1)
rdsparser.set_text_progressive(rds, 1, 1)
rdsparser.register_pi(rds, callbacks.pi);
rdsparser.register_pty(rds, callbacks.pty);
rdsparser.register_tp(rds, callbacks.tp);
rdsparser.register_ta(rds, callbacks.ta);
rdsparser.register_ms(rds, callbacks.ms);
rdsparser.register_ecc(rds, callbacks.ecc);
rdsparser.register_country(rds, callbacks.country);
rdsparser.register_af(rds, callbacks.af);
rdsparser.register_ps(rds, callbacks.ps);
rdsparser.register_rt(rds, callbacks.rt);
rdsparser.register_ptyn(rds, callbacks.ptyn);
rdsparser.register_ct(rds, callbacks.ct);
const decode_unicode = function(string) {
let length = rdsparser.string_get_length(string);
if (length) {
let content = rdsparser.string_get_content(string);
let array = koffi.decode(content, unicode_type + ' [' + length + ']');
return String.fromCodePoint.apply(String, array);
}
return '';
};
const decode_errors = function(string) {
let length = rdsparser.string_get_length(string);
if (length) {
let errors = rdsparser.string_get_errors(string);
let array = koffi.decode(errors, 'uint8_t [' + length + ']');
return Uint8Array.from(array).toString();
}
return '';
};
const updateInterval = 75; const updateInterval = 75;
// Initialize the data object // Initialize the data object
@@ -229,7 +23,9 @@ var dataToSend = {
ecc: null, ecc: null,
af: [], af: [],
rt0: '', rt0: '',
rt0_errors: '',
rt1: '', rt1: '',
rt1_errors: '',
rt_flag: '', rt_flag: '',
ims: 0, ims: 0,
eq: 0, eq: 0,
@@ -251,6 +47,8 @@ var dataToSend = {
users: 0, users: 0,
}; };
const rds = new RDSDecoder(dataToSend);
const filterMappings = { const filterMappings = {
'G11': { eq: 1, ims: 1 }, 'G11': { eq: 1, ims: 1 },
'G01': { eq: 0, ims: 1 }, 'G01': { eq: 0, ims: 1 },
@@ -283,7 +81,7 @@ function rdsReceived() {
function rdsReset() { function rdsReset() {
resetToDefault(dataToSend); resetToDefault(dataToSend);
dataToSend.af.length = 0; dataToSend.af.length = 0;
rdsparser.clear(rds); rds.clear();
if (rdsTimeoutTimer) { if (rdsTimeoutTimer) {
clearTimeout(rdsTimeoutTimer); clearTimeout(rdsTimeoutTimer);
rdsTimeoutTimer = null; rdsTimeoutTimer = null;
@@ -400,11 +198,18 @@ function handleData(wss, receivedData, rdsWss) {
data += (((errors & 0x03) == 0) ? modifiedData.slice(12, 16) : '----'); data += (((errors & 0x03) == 0) ? modifiedData.slice(12, 16) : '----');
const newDataString = "G:\r\n" + data + "\r\n\r\n"; const newDataString = "G:\r\n" + data + "\r\n\r\n";
const finalBuffer = Buffer.from(newDataString, 'utf-8'); client.send(newDataString);
client.send(finalBuffer);
}); });
rdsparser.parse_string(rds, modifiedData); var data2 = "";
for(var i = 0; i < modifiedData.length; i++) {
data2 += modifiedData[i]
if ((i + 1) % 4 === 0 && i !== modifiedData.length - 1) data2 += " ";
}
var data = [];
data2.split(" ").forEach((i) => {data.push(parseInt(i, 16))})
rds.decodeGroup(data[0], data[1], data[2], data[3], data[4])
legacyRdsPiBuffer = null; legacyRdsPiBuffer = null;
break; break;
} }
+114 -11
View File
@@ -9,6 +9,7 @@ const app = express();
const httpServer = http.createServer(app); const httpServer = http.createServer(app);
const WebSocket = require('ws'); const WebSocket = require('ws');
const wss = new WebSocket.Server({ noServer: true, perMessageDeflate: true }); const wss = new WebSocket.Server({ noServer: true, perMessageDeflate: true });
const chatWss = new WebSocket.Server({ noServer: true });
const rdsWss = new WebSocket.Server({ noServer: true }); const rdsWss = new WebSocket.Server({ noServer: true });
const pluginsWss = new WebSocket.Server({ noServer: true, perMessageDeflate: true }); const pluginsWss = new WebSocket.Server({ noServer: true, perMessageDeflate: true });
const fs = require('fs'); const fs = require('fs');
@@ -16,17 +17,16 @@ 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');
const { createChatServer } = require('./chat');
const { createAudioServer } = require('./stream/ws.js');
// File imports // File imports
const helpers = require('./helpers'); const helpers = require('./helpers');
const dataHandler = require('./datahandler'); const dataHandler = require('./datahandler');
const fmdxList = require('./fmdx_list'); const fmdxList = require('./fmdx_list');
const { logError, logInfo, logWarn } = require('./console'); const { logDebug, logError, logInfo, logWarn, logChat } = require('./console');
const storage = require('./storage'); const storage = require('./storage');
const { serverConfig, configExists } = require('./server_config'); const { serverConfig, configExists, configSave } = require('./server_config');
const pjson = require('../package.json'); const pjson = require('../package.json');
// Function to find server files based on the plugins listed in config // Function to find server files based on the plugins listed in config
@@ -90,8 +90,6 @@ console.log('\x1b[32m\x1b[2mby Noobish @ \x1b[4mFMDX.org\x1b[0m');
console.log("v" + pjson.version) console.log("v" + pjson.version)
console.log('\x1b[90m' + '─'.repeat(terminalWidth - 1) + '\x1b[0m'); console.log('\x1b[90m' + '─'.repeat(terminalWidth - 1) + '\x1b[0m');
const chatWss = createChatServer(storage);
const audioWss = createAudioServer();
// Start ffmpeg // Start ffmpeg
require('./stream/index'); require('./stream/index');
require('./plugins'); require('./plugins');
@@ -582,6 +580,84 @@ wss.on('connection', (ws, request) => {
ws.on('error', console.error); ws.on('error', console.error);
}); });
// CHAT WEBSOCKET BLOCK
chatWss.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;
}
// Send chat history to the newly connected client
storage.chatHistory.forEach(function(message) {
message.history = true;
!request.session.isAdminAuthenticated ? delete message.ip : null;
ws.send(JSON.stringify(message));
});
const ipMessage = {
type: 'clientIp',
ip: clientIp,
admin: request.session.isAdminAuthenticated
};
ws.send(JSON.stringify(ipMessage));
// Anti-spam tracking for each client
const userCommands = {};
let lastWarn = { time: 0 };
ws.on('message', function incoming(message) {
// Anti-spam
const command = helpers.antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, '5', 'chat');
let messageData;
try {
messageData = JSON.parse(message);
} catch (error) {
ws.send(JSON.stringify({ error: "Invalid message format" }));
return;
}
// Escape nickname and other potentially unsafe fields
if (messageData.nickname) {
messageData.nickname = helpers.escapeHtml(messageData.nickname);
}
messageData.ip = clientIp;
const currentTime = new Date();
const hours = String(currentTime.getHours()).padStart(2, '0');
const minutes = String(currentTime.getMinutes()).padStart(2, '0');
messageData.time = `${hours}:${minutes}`; // Adding current time to the message object in hours:minutes format
if (serverConfig.webserver.banlist?.includes(clientIp)) { return; }
if (request.session.isAdminAuthenticated === true) { messageData.admin = true; }
if (messageData.message.length > 255) { messageData.message = messageData.message.substring(0, 255); }
storage.chatHistory.push(messageData);
if (storage.chatHistory.length > 50) { storage.chatHistory.shift(); }
logChat(messageData);
chatWss.clients.forEach(function each(client) {
if (client.readyState === WebSocket.OPEN) {
// Only include IP for admin clients
let responseMessage = { ...messageData };
if (request.session.isAdminAuthenticated !== true) {
delete responseMessage.ip;
}
const modifiedMessage = JSON.stringify(responseMessage);
client.send(modifiedMessage);
}
});
});
ws.on('close', function close() {});
});
// Additional web socket for using plugins // Additional web socket for using plugins
pluginsWss.on('connection', (ws, request) => { pluginsWss.on('connection', (ws, request) => {
const clientIp = request.headers['x-forwarded-for'] || request.connection.remoteAddress; const clientIp = request.headers['x-forwarded-for'] || request.connection.remoteAddress;
@@ -617,11 +693,35 @@ pluginsWss.on('connection', (ws, request) => {
}); });
}); });
ws.on('close', () => {
// logInfo('WebSocket Extra connection closed'); // Use custom logInfo function
});
ws.on('error', error => { ws.on('error', error => {
logError('WebSocket Extra error: ' + error); // Use custom logError function logError('WebSocket Extra error: ' + error); // Use custom logError function
}); });
}); });
function isPortOpen(host, port, timeout = 1000) {
return new Promise((resolve) => {
const socket = new net.Socket();
const onError = () => {
socket.destroy();
resolve(false);
};
socket.setTimeout(timeout);
socket.once('error', onError);
socket.once('timeout', onError);
socket.connect(port, host, () => {
socket.end();
resolve(true);
});
});
}
// Websocket register for /text, /audio and /chat paths // Websocket register for /text, /audio and /chat paths
httpServer.on('upgrade', (request, socket, head) => { httpServer.on('upgrade', (request, socket, head) => {
if (request.url === '/text') { if (request.url === '/text') {
@@ -631,12 +731,15 @@ httpServer.on('upgrade', (request, socket, head) => {
}); });
}); });
} else if (request.url === '/audio') { } else if (request.url === '/audio') {
sessionMiddleware(request, {}, () => { if (typeof audioServer?.handleAudioUpgrade === 'function') {
audioWss.handleUpgrade(request, socket, head, (ws) => { audioServer.handleAudioUpgrade(request, socket, head, (ws) => {
audioWss.emit('connection', ws, request); audioServer.Server?.Server?.emit?.('connection', ws, request);
}); });
}); } else {
} else if (request.url === '/chat' && serverConfig.webserver.chatEnabled === true) { logWarn('[Audio WebSocket] Audio server not ready — dropping client connection.');
socket.destroy();
}
} 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);
+175
View File
@@ -0,0 +1,175 @@
const { rdsEccLookup, iso, countries } = require("./rds_country.js")
class RDSDecoder {
constructor(data) {
this.data = data;
this.clear()
}
clear() {
this.data.pi = '?';
this.ps = Array(8).fill(' ');
this.ps_errors = Array(8).fill("0");
this.rt0 = Array(64).fill(' ');
this.rt0_errors = Array(64).fill("0");
this.rt1 = Array(64).fill(' ');
this.rt1_errors = Array(64).fill("0");
this.data.ps = '';
this.data.rt1 = '';
this.data.rt0 = '';
this.data.pty = 0;
this.data.tp = 0;
this.data.ta = 0;
this.data.ms = -1;
this.data.rt_flag = 0;
this.rt1_to_clear = false;
this.rt0_to_clear = false;
this.data.ecc = null;
this.data.country_name = ""
this.data.country_iso = "UN"
this.af_len = 0;
this.data.af = []
this.af_am_follows = false;
}
decodeGroup(blockA, blockB, blockC, blockD, error) {
if(error > 2) return; // TODO: handle errors
// console.log(error)
this.data.pi = blockA.toString(16).toUpperCase().padStart(4, '0');
const group = (blockB >> 12) & 0xF;
const version = (blockB >> 11) & 0x1;
this.data.tp = Number((blockB >> 10) & 1);
this.data.pty = (blockB >> 5) & 0b11111;
if (group === 0) {
this.data.ta = (blockB >> 4) & 1;
this.data.ms = (blockB >> 3) & 1;
if(version === 0) {
var af_high = blockC >> 8;
var af_low = blockC & 0xFF;
var BASE = 224;
var FILLER = 205;
var AM_FOLLOWS = 250;
if(af_high >= BASE && af_high <= (BASE+25)) {
this.af_len = af_high-BASE;
if(this.af_len !== this.data.af.length) {
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;
}
} else if(this.data.af.length != this.af_len) {
if(!(af_high == AM_FOLLOWS || this.af_am_follows)) {
var freq = (af_high+875)*100;
if(!this.data.af.includes(freq)) this.data.af.push(freq);
}
if(this.af_am_follows) this.af_am_follows = false;
if(!(af_high == AM_FOLLOWS || af_low == FILLER || af_low == AM_FOLLOWS)) {
var freq = (af_low+875)*100;
if(!this.data.af.includes(freq)) this.data.af.push(freq);
}
if(af_low == AM_FOLLOWS) this.af_am_follows = true;
}
}
const idx = blockB & 0x3;
this.ps[idx * 2] = String.fromCharCode(blockD >> 8);
this.ps[idx * 2 + 1] = String.fromCharCode(blockD & 0xFF);
this.ps_errors[idx * 2] = error;
this.ps_errors[idx * 2 + 1] = error;
this.data.ps = this.ps.join('');
this.data.ps_errors = this.ps_errors.join(',');
} else if (group === 1 && version === 0) {
var la = Boolean(blockC & 0x8000);
var variant_code = (blockC >> 12) & 0x7;
switch (variant_code) {
case 0:
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)]
break;
default:
break;
}
} else if (group === 2) {
const idx = blockB & 0b1111;
this.rt_ab = Boolean((blockB >> 4) & 1);
// TODO: rt_errors
if(this.rt_ab) {
if(this.rt1_to_clear) {
this.rt1 = Array(64).fill(' ');
this.rt1_errors = Array(64).fill("0");
this.rt1_to_clear = false;
}
if(version == 0) {
this.rt1[idx * 4] = String.fromCharCode(blockC >> 8);
this.rt1[idx * 4 + 1] = String.fromCharCode(blockC & 0xFF);
this.rt1[idx * 4 + 2] = String.fromCharCode(blockD >> 8);
this.rt1[idx * 4 + 3] = String.fromCharCode(blockD & 0xFF);
this.rt1_errors[idx * 4] = error;
this.rt1_errors[idx * 4 + 1] = error;
this.rt1_errors[idx * 4 + 2] = error;
this.rt1_errors[idx * 4 + 3] = error;
} else {
this.rt1[idx * 2] = String.fromCharCode(blockD >> 8);
this.rt1[idx * 2 + 1] = String.fromCharCode(blockD & 0xFF);
this.rt1_errors[idx * 2] = error;
this.rt1_errors[idx * 2 + 1] = error;
}
var i = this.rt1.indexOf("\r")
while(i != -1) {
this.rt1[i] = " ";
i = this.rt1.indexOf("\r");
}
this.data.rt1 = this.rt1.join('');
this.data.rt1_errors = this.rt1_errors.join(',');
this.data.rt_flag = 1;
this.rt0_to_clear = true;
} else {
if(this.rt0_to_clear) {
this.rt0 = Array(64).fill(' ');
this.rt0_errors = Array(64).fill("0");
this.rt0_to_clear = false;
}
if(version == 0) {
this.rt0[idx * 4] = String.fromCharCode(blockC >> 8);
this.rt0[idx * 4 + 1] = String.fromCharCode(blockC & 0xFF);
this.rt0[idx * 4 + 2] = String.fromCharCode(blockD >> 8);
this.rt0[idx * 4 + 3] = String.fromCharCode(blockD & 0xFF);
this.rt0_errors[idx * 4] = error;
this.rt0_errors[idx * 4 + 1] = error;
this.rt0_errors[idx * 4 + 2] = error;
this.rt0_errors[idx * 4 + 3] = error;
} else {
this.rt0[idx * 2] = String.fromCharCode(blockD >> 8);
this.rt0[idx * 2 + 1] = String.fromCharCode(blockD & 0xFF);
this.rt0_errors[idx * 2] = error;
this.rt0_errors[idx * 2 + 1] = error;
}
var i = this.rt0.indexOf("\r");
while(i != -1) {
this.rt0[i] = " ";
i = this.rt0.indexOf("\r");
}
this.data.rt0 = this.rt0.join('');
this.data.rt0_errors = this.rt0_errors.join(',');
this.data.rt_flag = 0;
this.rt1_to_clear = true;
}
} else {
// console.log(group, version)
}
}
}
module.exports = RDSDecoder;
+632
View File
@@ -0,0 +1,632 @@
var countries = [
"Albania",
"Estonia",
"Algeria",
"Ethiopia",
"Andorra",
"Angola",
"Finland",
"Armenia",
"France",
"Ascension Island",
"Gabon",
"Austria",
"Gambia",
"Azerbaijan",
"Georgia",
"Germany",
"Bahrein",
"Ghana",
"Belarus",
"Gibraltar",
"Belgium",
"Greece",
"Benin",
"Guinea",
"Bosnia Herzegovina",
"Guinea-Bissau",
"Botswana",
"Hungary",
"Bulgaria",
"Iceland",
"Burkina Faso",
"Iraq",
"Burundi",
"Ireland",
"Cabinda",
"Israel",
"Cameroon",
"Italy",
"Jordan",
"Cape Verde",
"Kazakhstan",
"Central African Republic",
"Kenya",
"Chad",
"Kosovo",
"Comoros",
"Kuwait",
"DR Congo",
"Kyrgyzstan",
"Republic of Congo",
"Latvia",
"Cote d'Ivoire",
"Lebanon",
"Croatia",
"Lesotho",
"Cyprus",
"Liberia",
"Czechia",
"Libya",
"Denmark",
"Liechtenstein",
"Djiboutia",
"Lithuania",
"Egypt",
"Luxembourg",
"Equatorial Guinea",
"Macedonia",
"Eritrea",
"Madagascar",
"Seychelles",
"Malawi",
"Sierra Leone",
"Mali",
"Slovakia",
"Malta",
"Slovenia",
"Mauritania",
"Somalia",
"Mauritius",
"South Africa",
"Moldova",
"South Sudan",
"Monaco",
"Spain",
"Mongolia",
"Sudan",
"Montenegro",
"Swaziland",
"Morocco",
"Sweden",
"Mozambique",
"Switzerland",
"Namibia",
"Syria",
"Netherlands",
"Tajikistan",
"Niger",
"Tanzania",
"Nigeria",
"Togo",
"Norway",
"Tunisia",
"Oman",
"Turkey",
"Palestine",
"Turkmenistan",
"Poland",
"Uganda",
"Portugal",
"Ukraine",
"Qatar",
"United Arab Emirates",
"Romania",
"United Kingdom",
"Russia",
"Uzbekistan",
"Rwanda",
"Vatican",
"San Marino",
"Western Sahara",
"Sao Tome and Principe",
"Yemen",
"Saudi Arabia",
"Zambia",
"Senegal",
"Zimbabwe",
"Serbia",
"Anguilla",
"Guyana",
"Antigua and Barbuda",
"Haiti",
"Argentina",
"Honduras",
"Aruba",
"Jamaica",
"Bahamas",
"Martinique",
"Barbados",
"Mexico",
"Belize",
"Montserrat",
"Brazil/Bermuda",
"Brazil/AN",
"Bolivia",
"Nicaragua",
"Brazil",
"Panama",
"Canada",
"Paraguay",
"Cayman Islands",
"Peru",
"Chile",
"USA/VI/PR",
"Colombia",
"St. Kitts",
"Costa Rica",
"St. Lucia",
"Cuba",
"St. Pierre and Miquelon",
"Dominica",
"St. Vincent",
"Dominican Republic",
"Suriname",
"El Salvador",
"Trinidad and Tobago",
"Turks and Caicos islands",
"Falkland Islands",
"Greenland",
"Uruguay",
"Grenada",
"Venezuela",
"Guadeloupe",
"Virgin Islands",
"Guatemala",
"Afghanistan",
"South Korea",
"Laos",
"Australia Capital Territory",
"Macao",
"Australia New South Wales",
"Malaysia",
"Australia Victoria",
"Maldives",
"Australia Queensland",
"Marshall Islands",
"Australia South Australia",
"Micronesia",
"Australia Western Australia",
"Myanmar",
"Australia Tasmania",
"Nauru",
"Australia Northern Territory",
"Nepal",
"Bangladesh",
"New Zealand",
"Bhutan",
"Pakistan",
"Brunei Darussalam",
"Papua New Guinea",
"Cambodia",
"Philippines",
"China",
"Samoa",
"Singapore",
"Solomon Islands",
"Fiji",
"Sri Lanka",
"Hong Kong",
"Taiwan",
"India",
"Thailand",
"Indonesia",
"Tonga",
"Iran",
"Vanuatu",
"Japan",
"Vietnam",
"Kiribati",
"North Korea",
"Brazil/Equator"
]
var iso = [
"AL",
"EE",
"DZ",
"ET",
"AD",
"AO",
"FI",
"AM",
"FR",
"SH",
"GA",
"AT",
"GM",
"AZ",
"GE",
"DE",
"BH",
"GH",
"BY",
"GI",
"BE",
"GR",
"BJ",
"GN",
"BA",
"GW",
"BW",
"HU",
"BG",
"IS",
"BF",
"IQ",
"BI",
"IE",
"--",
"IL",
"CM",
"IT",
"JO",
"CV",
"KZ",
"CF",
"KE",
"TD",
"XK",
"KM",
"KW",
"CD",
"KG",
"CG",
"LV",
"CI",
"LB",
"HR",
"LS",
"CY",
"LR",
"CZ",
"LY",
"DK",
"LI",
"DJ",
"LT",
"EG",
"LU",
"GQ",
"MK",
"ER",
"MG",
"SC",
"MW",
"SL",
"ML",
"SK",
"MT",
"SI",
"MR",
"SO",
"MU",
"ZA",
"MD",
"SS",
"MC",
"ES",
"MN",
"SD",
"ME",
"SZ",
"MA",
"SE",
"MZ",
"CH",
"NA",
"SY",
"NL",
"TJ",
"NE",
"TZ",
"NG",
"TG",
"NO",
"TN",
"OM",
"TR",
"PS",
"TM",
"PL",
"UG",
"PT",
"UA",
"QA",
"AE",
"RO",
"GB",
"RU",
"UZ",
"RW",
"VA",
"SM",
"EH",
"ST",
"YE",
"SA",
"ZM",
"SN",
"ZW",
"RS",
"AI",
"GY",
"AG",
"HT",
"AR",
"HN",
"AW",
"JM",
"BS",
"MQ",
"BB",
"MX",
"BZ",
"MS",
"--",
"--",
"BO",
"NI",
"BR",
"PA",
"CA",
"PY",
"KY",
"PE",
"CL",
"--",
"CO",
"KN",
"CR",
"LC",
"CU",
"PM",
"DM",
"VC",
"DO",
"SR",
"SN",
"TT",
"TB",
"FK",
"GL",
"UY",
"GD",
"VE",
"GP",
"VG",
"GT",
"AF",
"KR",
"LA",
"AU",
"MO",
"AU",
"MY",
"AU",
"MV",
"AU",
"MH",
"AU",
"FM",
"AU",
"MM",
"AU",
"NR",
"AU",
"NP",
"BD",
"NZ",
"BT",
"PK",
"BN",
"PG",
"KH",
"PH",
"CN",
"WS",
"SG",
"SB",
"FJ",
"LK",
"HK",
"TW",
"IN",
"TH",
"ID",
"TO",
"IR",
"VU",
"JP",
"VN",
"KI",
"KP",
"--"
]
// RDS ECC Lookup Tables - Converted from C to JavaScript
const rdsEccA0A6Lut = [
// A0
[
"USA/VI/PR", "USA/VI/PR", "USA/VI/PR", "USA/VI/PR", "USA/VI/PR",
"USA/VI/PR", "USA/VI/PR", "USA/VI/PR", "USA/VI/PR", "USA/VI/PR",
"USA/VI/PR", null, "USA/VI/PR", "USA/VI/PR", null
],
// A1
[
null, null, null, null, null,
null, null, null, null, null,
"Canada", "Canada", "Canada", "Canada", "Greenland"
],
// A2
[
"Anguilla", "Antigua and Barbuda", "Brazil/Equator", "Falkland Islands", "Barbados",
"Belize", "Cayman Islands", "Costa Rica", "Cuba", "Argentina",
"Brazil", "Brazil/Bermuda", "Brazil/AN", "Guadeloupe", "Bahamas"
],
// A3
[
"Bolivia", "Colombia", "Jamaica", "Martinique", null,
"Paraguay", "Nicaragua", null, "Panama", "Dominica",
"Dominican Republic", "Chile", "Grenada", "Turks and Caicos islands", "Guyana"
],
// A4
[
"Guatemala", "Honduras", "Aruba", null, "Montserrat",
"Trinidad and Tobago", "Peru", "Suriname", "Uruguay", "St. Kitts",
"St. Lucia", "El Salvador", "Haiti", "Venezuela", "Virgin Islands"
],
// A5
[
null, null, null, null, null,
null, null, null, null, null,
"Mexico", "St. Vincent", "Mexico", "Mexico", "Mexico"
],
// A6
[
null, null, null, null, null,
null, null, null, null, null,
null, null, null, null, "St. Pierre and Miquelon"
]
];
const rdsEccD0D4Lut = [
// D0
[
"Cameroon", "Central African Republic", "Djiboutia", "Madagascar", "Mali",
"Angola", "Equatorial Guinea", "Gabon", "Guinea", "South Africa",
"Burkina Faso", "Republic of Congo", "Togo", "Benin", "Malawi"
],
// D1
[
"Namibia", "Liberia", "Ghana", "Mauritania", "Sao Tome and Principe",
"Cape Verde", "Senegal", "Gambia", "Burundi", "Ascension Island",
"Botswana", "Comoros", "Tanzania", "Ethiopia", "Nigeria"
],
// D2
[
"Sierra Leone", "Zimbabwe", "Mozambique", "Uganda", "Swaziland",
"Kenya", "Somalia", "Niger", "Chad", "Guinea-Bissau",
"DR Congo", "Cote d'Ivoire", null, "Zambia", "Eritrea"
],
// D3
[
null, null, "Western Sahara", "Cabinda", "Rwanda",
"Lesotho", null, "Seychelles", null, "Mauritius",
null, "Sudan", null, null, null
],
// D4
[
null, null, null, null, null,
null, null, null, null, "South Sudan",
null, null, null, null, null
]
];
const rdsEccE0E5Lut = [
// E0
[
"Germany", "Algeria", "Andorra", "Israel", "Italy",
"Belgium", "Russia", "Palestine", "Albania", "Austria",
"Hungary", "Malta", "Germany", null, "Egypt"
],
// E1
[
"Greece", "Cyprus", "San Marino", "Switzerland", "Jordan",
"Finland", "Luxembourg", "Bulgaria", "Denmark", "Gibraltar",
"Iraq", "United Kingdom", "Libya", "Romania", "France"
],
// E2
[
"Morocco", "Czechia", "Poland", "Vatican", "Slovakia",
"Syria", "Tunisia", null, "Liechtenstein", "Iceland",
"Monaco", "Lithuania", "Serbia", "Spain", "Norway"
],
// E3
[
"Montenegro", "Ireland", "Turkey", null, "Tajikistan",
null, null, "Netherlands", "Latvia", "Lebanon",
"Azerbaijan", "Croatia", "Kazakhstan", "Sweden", "Belarus"
],
// E4
[
"Moldova", "Estonia", "Macedonia", null, null,
"Ukraine", "Kosovo", "Portugal", "Slovenia", "Armenia",
"Uzbekistan", "Georgia", null, "Turkmenistan", "Bosnia Herzegovina"
],
// E5
[
null, null, "Kyrgyzstan", null, null,
null, null, null, null, null,
null, null, null, null, null
]
];
const rdsEccF0F4Lut = [
// F0
[
"Australia Capital Territory", "Australia New South Wales", "Australia Victoria", "Australia Queensland", "Australia South Australia",
"Australia Western Australia", "Australia Tasmania", "Australia Northern Territory", "Saudi Arabia", "Afghanistan",
"Myanmar", "China", "North Korea", "Bahrein", "Malaysia"
],
// F1
[
"Kiribati", "Bhutan", "Bangladesh", "Pakistan", "Fiji",
"Oman", "Nauru", "Iran", "New Zealand", "Solomon Islands",
"Brunei Darussalam", "Sri Lanka", "Taiwan", "South Korea", "Hong Kong"
],
// F2
[
"Kuwait", "Qatar", "Cambodia", "Samoa", "India",
"Macao", "Vietnam", "Philippines", "Japan", "Singapore",
"Maldives", "Indonesia", "United Arab Emirates", "Nepal", "Vanuatu"
],
// F3
[
"Laos", "Thailand", "Tonga", null, null,
null, null, "China", "Papua New Guinea", null,
"Yemen", null, null, "Micronesia", "Mongolia"
],
// F4
[
null, null, null, null, null,
null, null, null, "China", null,
"Marshall Islands", null, null, null, null
]
];
function rdsEccLookup(pi, ecc) {
const PI_UNKNOWN = -1;
const piCountry = (pi >> 12) & 0xF;
if (pi === PI_UNKNOWN || piCountry === 0) {
return ""
}
const piId = piCountry - 1;
const eccRanges = [
{ min: 0xA0, max: 0xA6, lut: rdsEccA0A6Lut },
{ min: 0xD0, max: 0xD4, lut: rdsEccD0D4Lut },
{ min: 0xE0, max: 0xE5, lut: rdsEccE0E5Lut },
{ min: 0xF0, max: 0xF4, lut: rdsEccF0F4Lut }
];
// Check each range
for (const range of eccRanges) {
if (ecc >= range.min && ecc <= range.max) {
const eccId = ecc - range.min;
return range.lut[eccId][piId];
}
}
return ""
}
module.exports = {
rdsEccLookup,
iso,
countries
};
+381
View File
@@ -0,0 +1,381 @@
"use strict";
/*
Stdin streamer is part of 3LAS (Low Latency Live Audio Streaming)
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) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
const fs_1 = require("fs");
const child_process_1 = require("child_process");
const ws = __importStar(require("ws"));
const Settings = JSON.parse(fs.readFileSync(path.resolve(__dirname, 'settings.json'), 'utf-8'));
const FFmpeg_command = ffmpegStaticPath;
class StreamClient {
constructor(server, socket) {
this.Server = server;
this.Socket = socket;
this.BinaryOptions = {
compress: false,
binary: true
};
this.Socket.on('error', this.OnError.bind(this));
this.Socket.on('message', this.OnMessage.bind(this));
}
OnMessage(message, isBinary) {
try {
let request = JSON.parse(message.toString());
if (request.type == "answer") {
}
else if (request.type == "fallback") {
this.Server.SetFallback(this, request.data);
}
else if (request.type == "stats") {
if (Settings.AdminKey && request.data == Settings.AdminKey) {
this.SendText(JSON.stringify({
"type": "stats",
"data": this.Server.GetStats(),
}));
}
}
else {
this.OnError(null);
return;
}
}
catch (_a) {
this.OnError(null);
return;
}
}
OnError(_err) {
this.Server.DestroyClient(this);
}
Destroy() {
try {
this.Socket.close();
}
catch (ex) {
}
}
SendBinary(buffer) {
if (this.Socket.readyState != ws.OPEN) {
this.OnError(null);
return;
}
this.Socket.send(buffer, this.BinaryOptions);
}
SendText(text) {
if (this.Socket.readyState != ws.OPEN) {
this.OnError(null);
return;
}
this.Socket.send(text);
}
OnIceCandidate(e) {
if (e.candidate) {
this.SendText(JSON.stringify({
"type": "candidate",
"data": e.candidate
}));
}
}
}
class StreamServer {
constructor(port, channels, sampleRate) {
this.Port = port || null;
this.Channels = channels;
this.SampleRate = sampleRate;
this.Clients = new Set();
this.FallbackClients = {
"wav": new Set(),
"mp3": new Set()
};
this.FallbackProvider = {};
if (Settings.FallbackUseMp3) {
this.FallbackProvider["mp3"] = AFallbackProvider.Create(this, "mp3");
}
if (Settings.FallbackUseWav) {
this.FallbackProvider["wav"] = AFallbackProvider.Create(this, "wav");
}
this.StdIn = process.stdin;
this.SamplesCount = this.SampleRate / 100;
this.Samples = new Int16Array(this.Channels * this.SamplesCount);
this.SamplesPosition = 0;
}
Run() {
this.Server = new ws.Server({
noServer: true,
clientTracking: true,
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));
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.resume();
}
BroadcastBinary(format, buffer) {
this.FallbackClients[format].forEach((function each(client) {
client.SendBinary(buffer);
}).bind(this));
}
OnStdInData(buffer) {
for (let i = 0; i < buffer.length; i += 2) {
this.Samples[this.SamplesPosition] = buffer.readInt16LE(i);
this.SamplesPosition++;
if (this.SamplesPosition >= this.Samples.length) {
let data = {
"samples": this.Samples,
"sampleRate": this.SampleRate,
"bitsPerSample": 16,
"channelCount": this.Channels,
"numberOfFrames": this.SamplesCount,
};
this.Samples = new Int16Array(this.Channels * this.SamplesCount);
this.SamplesPosition = 0;
}
}
for (let format in this.FallbackProvider) {
this.FallbackProvider[format].InsertData(buffer);
}
}
OnServerConnection(socket, _request) {
this.Clients.add(new StreamClient(this, socket));
}
SetFallback(client, format) {
if (format != "mp3" && format != "wav") {
this.DestroyClient(client);
return;
}
this.FallbackClients[format].add(client);
this.FallbackProvider[format].PrimeClient(client);
}
DestroyClient(client) {
this.FallbackClients["mp3"].delete(client);
this.FallbackClients["wav"].delete(client);
this.Clients.delete(client);
client.Destroy();
}
GetStats() {
let fallback = {
"wav": (this.FallbackClients["wav"] ? this.FallbackClients["wav"].size : 0),
"mp3": (this.FallbackClients["mp3"] ? this.FallbackClients["mp3"].size : 0),
};
let total = 0;
for (let format in fallback) {
total += fallback[format];
}
return {
"Total": total,
"Fallback": fallback,
};
}
static Create(options) {
// Allow Port to be omitted
const port = options["-port"] || null;
if (!options["-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"]) ||
!(options["-channels"] == 1 || options["-channels"] == 2))
throw new Error("Invalid channels. Must be either 1 or 2.");
if (!options["-samplerate"])
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)
throw new Error("Invalid sample rate. Must be natural number greater than 0.");
return new StreamServer(port, options["-channels"], options["-samplerate"]);
}
}
class AFallbackProvider {
constructor(server) {
this.Server = server;
this.Process = (0, child_process_1.spawn)(FFmpeg_command, this.GetFFmpegArguments(), { shell: false, detached: false, stdio: ['pipe', 'pipe', 'ignore'] });
this.Process.stdout.addListener('data', this.OnData.bind(this));
}
InsertData(buffer) {
this.Process.stdin.write(buffer);
}
static Create(server, format) {
if (format == "mp3") {
return new FallbackProviderMp3(server);
}
else if (format == "wav") {
return new FallbackProviderWav(server, 384);
}
}
}
class FallbackProviderMp3 extends AFallbackProvider {
constructor(server) {
super(server);
}
GetFFmpegArguments() {
return [
"-fflags", "+nobuffer+flush_packets", "-flags", "low_delay", "-rtbufsize", "32", "-probesize", "32",
"-f", "s16le",
"-ar", Number(this.Server.SampleRate.toString()) + Number(serverConfig.audio.samplerateOffset),
"-ac", this.Server.Channels.toString(),
"-i", "pipe:0",
"-c:a", "libmp3lame",
"-b:a", serverConfig.audio.audioBitrate,
"-ac", this.Server.Channels.toString(),
"-reservoir", "0",
"-f", "mp3", "-write_xing", "0", "-id3v2_version", "0",
"-fflags", "+nobuffer", "-flush_packets", "1",
"pipe:1"
];
}
OnData(chunk) {
this.Server.BroadcastBinary("mp3", chunk);
}
PrimeClient(_) {
}
}
class FallbackProviderWav extends AFallbackProvider {
constructor(server, chunkSize) {
super(server);
if (typeof chunkSize !== "number" || chunkSize !== Math.floor(chunkSize) || chunkSize < 1)
throw new Error("Invalid ChunkSize. Must be natural number greater than or equal to 1.");
this.ChunkSize = chunkSize;
this.ChunkBuffer = Buffer.alloc(0);
this.HeaderBuffer = new Array();
}
GetFFmpegArguments() {
return [
"-fflags", "+nobuffer+flush_packets", "-flags", "low_delay", "-rtbufsize", "32", "-probesize", "32",
"-f", "s16le",
"-ar", Number(this.Server.SampleRate.toString()) + Number(serverConfig.audio.samplerateOffset),
"-ac", this.Server.Channels.toString(),
"-i", "pipe:0",
"-c:a", "pcm_s16le",
"-ar", Settings.FallbackWavSampleRate.toString(),
"-ac", "1",
"-f", "wav",
"-flush_packets", "1", "-fflags", "+nobuffer", "-chunk_size", "384", "-packetsize", "384",
"pipe:1"
];
}
OnData(chunk) {
// Check if riff for wav
if (this.HeaderBuffer.length == 0) {
// Check if chunk is a header page
let isHeader = (chunk[0] == 0x52 && chunk[1] == 0x49 && chunk[2] == 0x46 && chunk[3] == 0x46);
if (isHeader) {
this.HeaderBuffer.push(chunk);
this.Server.BroadcastBinary("wav", chunk);
}
}
else {
this.ChunkBuffer = Buffer.concat(new Array(this.ChunkBuffer, chunk), this.ChunkBuffer.length + chunk.length);
if (this.ChunkBuffer.length >= this.ChunkSize) {
let chunkBuffer = this.ChunkBuffer;
this.ChunkBuffer = Buffer.alloc(0);
this.Server.BroadcastBinary("wav", chunkBuffer);
}
}
}
PrimeClient(client) {
let headerBuffer = this.HeaderBuffer;
for (let i = 0; i < headerBuffer.length; i++) {
client.SendBinary(headerBuffer[i]);
}
}
}
/* Parsing parameters no longer required for Server variable but we'll keep the old code here as a reference
const OptionParser = {
"-port": function (txt) { return parseInt(txt, 10); },
"-channels": function (txt) { return parseInt(txt, 10); },
"-samplerate": function (txt) { return parseInt(txt, 10); }
};
const Options = {};
// Parse parameters
for (let i = 2; i < (process.argv.length - 1); i += 2) {
if (!OptionParser[process.argv[i]])
throw new Error("Invalid argument: '" + process.argv[i] + "'.");
if (Options[process.argv[i]])
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]);
}
*/
const audioChannels = serverConfig.audio.audioChannels || 2;
const Server = new StreamServer(null, audioChannels, 48000);
ServerInstance = Server;
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
+368 -112
View File
@@ -1,8 +1,8 @@
const { spawn } = require('child_process'); const { spawn, execSync } = require('child_process');
const { serverConfig } = 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 { PassThrough } = require('stream'); const audioServer = require('./3las.server');
const consoleLogTitle = '[Audio Stream]'; const consoleLogTitle = '[Audio Stream]';
@@ -15,128 +15,384 @@ function connectMessage(message) {
} }
} }
const audio_pipe = new PassThrough(); function checkAudioUtilities() {
if (process.platform === 'darwin') {
try {
execSync('which sox');
} catch (error) {
logError(`${consoleLogTitle} Error: SoX ("sox") not found, Please install sox.`);
process.exit(1);
}
} else if (process.platform === 'linux') {
try {
execSync('which arecord');
} catch (error) {
logError(`${consoleLogTitle} Error: ALSA ("arecord") not found. Please install ALSA utils.`);
process.exit(1);
}
}
}
function buildCommand(ffmpegPath) {
const inputDevice = serverConfig.audio.audioDevice || 'Stereo Mix';
const audioChannels = serverConfig.audio.audioChannels || 2;
const webPort = Number(serverConfig.webserver.webserverPort);
// Common audio options for FFmpeg
const baseOptions = {
flags: ['-fflags', '+nobuffer+flush_packets', '-flags', 'low_delay', '-rtbufsize', '6192', '-probesize', '32'],
codec: ['-acodec', 'pcm_s16le', '-ar', '48000', '-ac', `${audioChannels}`],
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']
};
// Windows
if (process.platform === 'win32') {
logInfo(`${consoleLogTitle} Platform: Windows (win32). Using "dshow" input.`);
return {
command: ffmpegPath,
args: [
...baseOptions.flags,
'-f', 'dshow',
'-audio_buffer_size', '200',
'-i', `audio=${inputDevice}`,
...baseOptions.codec,
...baseOptions.output
]
};
} else if (process.platform === 'darwin') {
// macOS
if (!serverConfig.audio.ffmpeg) {
logInfo(`${consoleLogTitle} Platform: macOS (darwin) using "coreaudio"`);
return {
args: [],
soxArgs: [
'-t', 'coreaudio', `${inputDevice}`,
'-b', '32',
'-r', '48000',
'-c', `${audioChannels}`,
'-t', 'raw',
'-b', '16',
'-r', '48000',
'-c', `${audioChannels}`
, '-'
]
};
} else {
const device = serverConfig.audio.audioDevice;
return {
command: ffmpegPath,
args: [
...baseOptions.flags,
'-f', 'avfoundation',
'-i', `${device || ':0'}`,
...baseOptions.codec,
...baseOptions.output
]
};
}
} else {
// Linux
if (!serverConfig.audio.ffmpeg) {
const prefix = serverConfig.audio.softwareMode ? 'plug' : '';
const device = `${prefix}${serverConfig.audio.audioDevice}`;
logInfo(`${consoleLogTitle} Platform: Linux. Using "alsa" input.`);
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`,
args: [],
arecordArgs: [
'-D', device,
'-f', 'S16_LE',
'-r', '48000',
'-c', audioChannels,
'-t', 'raw'
],
ffmpegArgs: []
};
} else {
const device = serverConfig.audio.audioDevice;
return {
command: ffmpegPath,
args: [
...baseOptions.flags,
'-f', 'alsa',
'-i', `${device}`,
...baseOptions.codec,
...baseOptions.output
],
arecordArgs: [],
};
}
}
}
checkFFmpeg().then((ffmpegPath) => { checkFFmpeg().then((ffmpegPath) => {
logInfo(`${consoleLogTitle} Using ${ffmpegPath === 'ffmpeg' ? 'system-installed FFmpeg' : 'ffmpeg-static'}`); if (!serverConfig.audio.ffmpeg) checkAudioUtilities();
logInfo(`${consoleLogTitle} Starting audio stream on device: \x1b[35m${serverConfig.audio.audioDevice}\x1b[0m`); let audioErrorLogged = false;
const sampleRate = Number(this?.Server?.SampleRate || serverConfig.audio.sampleRate || 44100) + Number(serverConfig.audio.samplerateOffset || 0); logInfo(`${consoleLogTitle} Using`, ffmpegPath === 'ffmpeg' ? 'system-installed FFmpeg' : 'ffmpeg-static');
const channels = Number(this?.Server?.Channels || serverConfig.audio.audioChannels || 2); if (process.platform !== 'darwin') {
logInfo(`${consoleLogTitle} Starting audio stream on device: \x1b[35m${serverConfig.audio.audioDevice}\x1b[0m`);
let ffmpeg = null; } else {
let restartTimer = null; logInfo(`${consoleLogTitle} Starting audio stream on default input device.`);
let lastTimestamp = null;
let staleCount = 0;
let lastCheckTime = Date.now();
function buildArgs() {
const device = serverConfig.audio.audioDevice;
let inputArgs;
if (process.platform === 'win32') inputArgs = ["-f", "dshow", "-i", `audio=${device}`];
else if (process.platform === 'darwin') inputArgs = ["-f", "avfoundation", "-i", device || ":0"];
else inputArgs = ["-f", "alsa", "-i", device];
return [
"-fflags", "+flush_packets",
"-flags", "low_delay",
"-rtbufsize", "4096",
"-probesize", "128",
...inputArgs,
"-thread_queue_size", "1024",
"-ar", String(sampleRate),
"-ac", String(channels),
"-c:a", "libmp3lame",
"-b:a", serverConfig.audio.audioBitrate,
"-ac", String(channels),
"-reservoir", "0",
"-f", "mp3",
"-write_xing", "0",
"-id3v2_version", "0",
"-fflags", "+nobuffer",
"-flush_packets", "1",
"pipe:1"
];
} }
function launchFFmpeg() { if (process.platform === 'win32') {
const args = buildArgs(); // Windows (FFmpeg DirectShow Capture)
let ffmpeg;
let restartTimer = null;
let lastTimestamp = null;
let lastCheckTime = Date.now();
let audioErrorLogged = false;
let staleCount = 0;
logDebug(`${consoleLogTitle} Launching FFmpeg with args: ${args.join(' ')}`); function launchFFmpeg() {
const commandDef = buildCommand(ffmpegPath);
let ffmpegArgs = commandDef.args;
ffmpeg = spawn(ffmpegPath, args, {stdio: ['ignore', 'pipe', 'pipe']}); // Apply audio boost if enabled
if (serverConfig.audio.audioBoost) {
ffmpeg.stdout.pipe(audio_pipe, { end: false }); ffmpegArgs.splice(ffmpegArgs.indexOf('pipe:1'), 0, '-af', 'volume=1.7');
connectMessage(`${consoleLogTitle} Connected FFmpeg → MP3 → audioWss`);
ffmpeg.stderr.on('data', (data) => {
const msg = data.toString();
logFfmpeg(`[FFmpeg stderr]: ${msg}`);
// Detect frozen timestamps
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) {
staleCount++;
const now = Date.now();
if (staleCount >= 10 && now - lastCheckTime > 10000 && !restartTimer) {
logWarn(`${consoleLogTitle} FFmpeg appears frozen. Restarting...`);
restartTimer = setTimeout(() => {
restartTimer = null;
staleCount = 0;
try {
ffmpeg.kill('SIGKILL');
} catch (e) {
logWarn(`${consoleLogTitle} Failed to kill FFmpeg: ${e.message}`);
}
launchFFmpeg();
}, 0);
}
} else {
lastTimestamp = totalSec;
lastCheckTime = Date.now();
staleCount = 0;
}
} }
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 (sox --> 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.soxArgs.indexOf('pipe:1'), 0, '-af', 'volume=1.7');
}
let currentSox = null;
process.on('exit', () => {
if (currentSox) currentSox.kill('SIGINT');
}); });
ffmpeg.on('exit', (code, signal) => { process.on('SIGINT', () => {
if (signal) logWarn(`${consoleLogTitle} FFmpeg killed with signal ${signal}`); if (currentSox) currentSox.kill('SIGINT');
else if (code !== 0) logWarn(`${consoleLogTitle} FFmpeg exited with code ${code}`); process.exit();
logWarn(`${consoleLogTitle} Restarting FFmpeg in 5 seconds...`);
setTimeout(launchFFmpeg, 5000);
}); });
function startSox() {
if (!serverConfig.audio.ffmpeg) {
// Spawn sox
logDebug(`${consoleLogTitle} Launching sox with args: ${commandDef.soxArgs.join(' ')}`);
const sox = spawn('sox', commandDef.soxArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
currentSox = sox;
audioServer.waitUntilReady.then(() => {
audioServer.Server.StdIn = sox.stdout;
audioServer.Server.Run();
connectMessage(`${consoleLogTitle} Connected SoX \u2192 FFmpeg \u2192 Server.StdIn${serverConfig.audio.audioBoost && serverConfig.audio.ffmpeg ? ' (audio boost)' : ''}`);
});
sox.stderr.on('data', (data) => {
logFfmpeg(`[sox stderr]: ${data}`);
});
sox.on('exit', (code) => {
logFfmpeg(`[sox exited] with code ${code}`);
if (code !== 0) {
setTimeout(startSox, 2000);
}
});
}
}
startSox();
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=1.7');
}
let currentArecord = null;
process.on('exit', () => {
if (currentArecord) currentArecord.kill('SIGINT');
});
process.on('SIGINT', () => {
if (currentArecord) currentArecord.kill('SIGINT');
process.exit();
});
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'] });
currentArecord = arecord;
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)' : ''}`);
});
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}`);
}
});
}
} }
process.on('SIGINT', () => {
if (ffmpeg) ffmpeg.kill('SIGINT');
process.exit();
});
process.on('exit', () => {
if (ffmpeg) ffmpeg.kill('SIGINT');
});
launchFFmpeg();
}).catch((err) => { }).catch((err) => {
logError(`${consoleLogTitle} Error: ${err.message}`); logError(`${consoleLogTitle} Error: ${err.message}`);
}); });
module.exports.audio_pipe = audio_pipe;
+9
View File
@@ -0,0 +1,9 @@
{
"RtcConfig" : null,
"FallbackFFmpegPath": "ffmpeg.exe",
"FallbackUseMp3": true,
"FallbackUseWav": false,
"FallbackMp3Bitrate": 192,
"FallbackWavSampleRate": 16000,
"AdminKey": ""
}
-40
View File
@@ -1,40 +0,0 @@
const WebSocket = require('ws');
const { serverConfig } = require('../server_config');
const { audio_pipe } = require('./index.js');
const { PassThrough } = require('stream');
function createAudioServer() {
const audioWss = new WebSocket.Server({ noServer: true });
audioWss.on('connection', (ws, request) => {
const clientIp =
request.headers['x-forwarded-for'] ||
request.connection.remoteAddress;
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
});
}
});
});
audio_pipe.on('end', () => {
audioWss.clients.forEach((client) => {
client.close(1001, "Audio stream ended");
});
});
return audioWss;
}
module.exports = { createAudioServer };