You've already forked fm-dx-webserver
mirror of
https://github.com/KubaPro010/fm-dx-webserver.git
synced 2026-02-26 14:11:59 +01:00
Compare commits
7 Commits
34363b6c3b
...
d0a26d2346
| Author | SHA1 | Date | |
|---|---|---|---|
|
d0a26d2346
|
|||
|
0a7c56ff1d
|
|||
|
5a321562bd
|
|||
|
915f2c7e58
|
|||
|
6cabd1a75a
|
|||
|
028cd2e587
|
|||
|
|
32782d6704 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,7 +2,7 @@ node_modules/
|
||||
/*.json
|
||||
/serverlog.txt
|
||||
/web/js/plugins/
|
||||
/libraries/**
|
||||
/libraries/
|
||||
/plugins/*
|
||||
!/plugins/example/frontend.js
|
||||
!/plugins/example.js
|
||||
|
||||
42
package-lock.json
generated
42
package-lock.json
generated
@@ -6,7 +6,7 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "fm-dx-webserver",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.12",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@mapbox/node-pre-gyp": "2.0.0",
|
||||
@@ -516,20 +516,6 @@
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"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": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -1353,18 +1339,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz",
|
||||
@@ -1891,20 +1865,6 @@
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
||||
121
server/chat.js
Normal file
121
server/chat.js
Normal file
@@ -0,0 +1,121 @@
|
||||
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 };
|
||||
125
server/index.js
125
server/index.js
@@ -9,7 +9,6 @@ const app = express();
|
||||
const httpServer = http.createServer(app);
|
||||
const WebSocket = require('ws');
|
||||
const wss = new WebSocket.Server({ noServer: true, perMessageDeflate: true });
|
||||
const chatWss = new WebSocket.Server({ noServer: true });
|
||||
const rdsWss = new WebSocket.Server({ noServer: true });
|
||||
const pluginsWss = new WebSocket.Server({ noServer: true, perMessageDeflate: true });
|
||||
const fs = require('fs');
|
||||
@@ -17,16 +16,17 @@ const path = require('path');
|
||||
const net = require('net');
|
||||
const client = new net.Socket();
|
||||
const { SerialPort } = require('serialport');
|
||||
const audioServer = require('./stream/3las.server');
|
||||
const tunnel = require('./tunnel');
|
||||
const { createChatServer } = require('./chat');
|
||||
const { createAudioServer } = require('./stream/ws.js');
|
||||
|
||||
// File imports
|
||||
const helpers = require('./helpers');
|
||||
const dataHandler = require('./datahandler');
|
||||
const fmdxList = require('./fmdx_list');
|
||||
const { logDebug, logError, logInfo, logWarn, logChat } = require('./console');
|
||||
const { logError, logInfo, logWarn } = require('./console');
|
||||
const storage = require('./storage');
|
||||
const { serverConfig, configExists, configSave } = require('./server_config');
|
||||
const { serverConfig, configExists } = require('./server_config');
|
||||
const pjson = require('../package.json');
|
||||
|
||||
// Function to find server files based on the plugins listed in config
|
||||
@@ -90,6 +90,8 @@ console.log('\x1b[32m\x1b[2mby Noobish @ \x1b[4mFMDX.org\x1b[0m');
|
||||
console.log("v" + pjson.version)
|
||||
console.log('\x1b[90m' + '─'.repeat(terminalWidth - 1) + '\x1b[0m');
|
||||
|
||||
const chatWss = createChatServer(storage);
|
||||
const audioWss = createAudioServer();
|
||||
// Start ffmpeg
|
||||
require('./stream/index');
|
||||
require('./plugins');
|
||||
@@ -580,84 +582,6 @@ wss.on('connection', (ws, request) => {
|
||||
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
|
||||
pluginsWss.on('connection', (ws, request) => {
|
||||
const clientIp = request.headers['x-forwarded-for'] || request.connection.remoteAddress;
|
||||
@@ -693,35 +617,11 @@ pluginsWss.on('connection', (ws, request) => {
|
||||
});
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
// logInfo('WebSocket Extra connection closed'); // Use custom logInfo function
|
||||
});
|
||||
|
||||
ws.on('error', error => {
|
||||
logError('WebSocket Extra error: ' + error); // Use custom logError function
|
||||
});
|
||||
});
|
||||
|
||||
function isPortOpen(host, port, timeout = 1000) {
|
||||
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
|
||||
httpServer.on('upgrade', (request, socket, head) => {
|
||||
if (request.url === '/text') {
|
||||
@@ -731,15 +631,12 @@ httpServer.on('upgrade', (request, socket, head) => {
|
||||
});
|
||||
});
|
||||
} else if (request.url === '/audio') {
|
||||
if (typeof audioServer?.handleAudioUpgrade === 'function') {
|
||||
audioServer.handleAudioUpgrade(request, socket, head, (ws) => {
|
||||
audioServer.Server?.Server?.emit?.('connection', ws, request);
|
||||
sessionMiddleware(request, {}, () => {
|
||||
audioWss.handleUpgrade(request, socket, head, (ws) => {
|
||||
audioWss.emit('connection', ws, request);
|
||||
});
|
||||
} else {
|
||||
logWarn('[Audio WebSocket] Audio server not ready — dropping client connection.');
|
||||
socket.destroy();
|
||||
}
|
||||
} else if (request.url === '/chat') {
|
||||
});
|
||||
} else if (request.url === '/chat' && serverConfig.webserver.chatEnabled === true) {
|
||||
sessionMiddleware(request, {}, () => {
|
||||
chatWss.handleUpgrade(request, socket, head, (ws) => {
|
||||
chatWss.emit('connection', ws, request);
|
||||
|
||||
@@ -1,381 +0,0 @@
|
||||
"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
|
||||
@@ -1,8 +1,8 @@
|
||||
const { spawn, execSync } = require('child_process');
|
||||
const { configName, serverConfig, configUpdate, configSave, configExists } = require('../server_config');
|
||||
const { spawn } = require('child_process');
|
||||
const { serverConfig } = require('../server_config');
|
||||
const { logDebug, logError, logInfo, logWarn, logFfmpeg } = require('../console');
|
||||
const checkFFmpeg = require('./checkFFmpeg');
|
||||
const audioServer = require('./3las.server');
|
||||
const { PassThrough } = require('stream');
|
||||
|
||||
const consoleLogTitle = '[Audio Stream]';
|
||||
|
||||
@@ -15,384 +15,128 @@ function connectMessage(message) {
|
||||
}
|
||||
}
|
||||
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
const audio_pipe = new PassThrough();
|
||||
|
||||
checkFFmpeg().then((ffmpegPath) => {
|
||||
if (!serverConfig.audio.ffmpeg) checkAudioUtilities();
|
||||
let audioErrorLogged = false;
|
||||
logInfo(`${consoleLogTitle} Using ${ffmpegPath === 'ffmpeg' ? 'system-installed FFmpeg' : 'ffmpeg-static'}`);
|
||||
logInfo(`${consoleLogTitle} Starting audio stream on device: \x1b[35m${serverConfig.audio.audioDevice}\x1b[0m`);
|
||||
|
||||
logInfo(`${consoleLogTitle} Using`, ffmpegPath === 'ffmpeg' ? 'system-installed FFmpeg' : 'ffmpeg-static');
|
||||
const sampleRate = Number(this?.Server?.SampleRate || serverConfig.audio.sampleRate || 44100) + Number(serverConfig.audio.samplerateOffset || 0);
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
logInfo(`${consoleLogTitle} Starting audio stream on device: \x1b[35m${serverConfig.audio.audioDevice}\x1b[0m`);
|
||||
} else {
|
||||
logInfo(`${consoleLogTitle} Starting audio stream on default input device.`);
|
||||
const channels = Number(this?.Server?.Channels || serverConfig.audio.audioChannels || 2);
|
||||
|
||||
let ffmpeg = null;
|
||||
let restartTimer = null;
|
||||
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"
|
||||
];
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// Windows (FFmpeg DirectShow Capture)
|
||||
let ffmpeg;
|
||||
let restartTimer = null;
|
||||
let lastTimestamp = null;
|
||||
let lastCheckTime = Date.now();
|
||||
let audioErrorLogged = false;
|
||||
let staleCount = 0;
|
||||
function launchFFmpeg() {
|
||||
const args = buildArgs();
|
||||
|
||||
function launchFFmpeg() {
|
||||
const commandDef = buildCommand(ffmpegPath);
|
||||
let ffmpegArgs = commandDef.args;
|
||||
logDebug(`${consoleLogTitle} Launching FFmpeg with args: ${args.join(' ')}`);
|
||||
|
||||
// Apply audio boost if enabled
|
||||
if (serverConfig.audio.audioBoost) {
|
||||
ffmpegArgs.splice(ffmpegArgs.indexOf('pipe:1'), 0, '-af', 'volume=1.7');
|
||||
}
|
||||
ffmpeg = spawn(ffmpegPath, args, {stdio: ['ignore', 'pipe', 'pipe']});
|
||||
|
||||
logDebug(`${consoleLogTitle} Launching FFmpeg with args: ${ffmpegArgs.join(' ')}`);
|
||||
ffmpeg = spawn(ffmpegPath, ffmpegArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
ffmpeg.stdout.pipe(audio_pipe, { end: false });
|
||||
|
||||
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)' : ''}`);
|
||||
});
|
||||
connectMessage(`${consoleLogTitle} Connected FFmpeg → MP3 → audioWss`);
|
||||
|
||||
ffmpeg.stderr.on('data', (data) => {
|
||||
const msg = data.toString();
|
||||
logFfmpeg(`[FFmpeg stderr]: ${msg}`);
|
||||
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 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);
|
||||
|
||||
// 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) {
|
||||
staleCount++;
|
||||
const now = Date.now();
|
||||
|
||||
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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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}`);
|
||||
}
|
||||
lastTimestamp = totalSec;
|
||||
lastCheckTime = Date.now();
|
||||
staleCount = 0;
|
||||
}
|
||||
|
||||
// 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');
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
if (currentSox) currentSox.kill('SIGINT');
|
||||
process.exit();
|
||||
});
|
||||
|
||||
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();
|
||||
ffmpeg.on('exit', (code, signal) => {
|
||||
if (signal) logWarn(`${consoleLogTitle} FFmpeg killed with signal ${signal}`);
|
||||
else if (code !== 0) logWarn(`${consoleLogTitle} FFmpeg exited with code ${code}`);
|
||||
|
||||
logWarn(`${consoleLogTitle} Restarting FFmpeg in 5 seconds...`);
|
||||
setTimeout(launchFFmpeg, 5000);
|
||||
});
|
||||
|
||||
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) => {
|
||||
logError(`${consoleLogTitle} Error: ${err.message}`);
|
||||
});
|
||||
|
||||
module.exports.audio_pipe = audio_pipe;
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"RtcConfig" : null,
|
||||
"FallbackFFmpegPath": "ffmpeg.exe",
|
||||
"FallbackUseMp3": true,
|
||||
"FallbackUseWav": false,
|
||||
"FallbackMp3Bitrate": 192,
|
||||
"FallbackWavSampleRate": 16000,
|
||||
"AdminKey": ""
|
||||
}
|
||||
40
server/stream/ws.js
Normal file
40
server/stream/ws.js
Normal file
@@ -0,0 +1,40 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user