1
0
mirror of https://github.com/KubaPro010/fm-dx-webserver.git synced 2026-02-26 14:11:59 +01:00

Merge pull request #160 from AmateurAudioDude/update/v1.3.9-remove-audio-proxy

Remove need for audio proxy (http-proxy)
This commit is contained in:
Marek Farkaš
2025-08-30 17:29:16 +02:00
committed by GitHub
3 changed files with 484 additions and 192 deletions

View File

@@ -4,7 +4,6 @@ const endpoints = require('./endpoints');
const session = require('express-session'); const session = require('express-session');
const bodyParser = require('body-parser'); const bodyParser = require('body-parser');
const http = require('http'); const http = require('http');
const httpProxy = require('http-proxy');
const readline = require('readline'); const readline = require('readline');
const app = express(); const app = express();
const httpServer = http.createServer(app); const httpServer = http.createServer(app);
@@ -18,6 +17,7 @@ const path = require('path');
const net = require('net'); const net = require('net');
const client = new net.Socket(); const client = new net.Socket();
const { SerialPort } = require('serialport'); const { SerialPort } = require('serialport');
const audioServer = require('./stream/3las.server');
const tunnel = require('./tunnel'); const tunnel = require('./tunnel');
// File imports // File imports
@@ -94,13 +94,6 @@ console.log('\x1b[90m' + '─'.repeat(terminalWidth - 1) + '\x1b[0m');
require('./stream/index'); require('./stream/index');
require('./plugins'); require('./plugins');
// Create a WebSocket proxy instance
const proxy = httpProxy.createProxyServer({
target: 'ws://localhost:' + (Number(serverConfig.webserver.webserverPort) + 10), // WebSocket httpServer's address
ws: true, // Enable WebSocket proxying
changeOrigin: true // Change the origin of the host header to the target URL
});
let currentUsers = 0; let currentUsers = 0;
let serialport; let serialport;
@@ -390,12 +383,12 @@ wss.on('connection', (ws, request) => {
const userCommandHistory = {}; const userCommandHistory = {};
const normalizedClientIp = clientIp?.replace(/^::ffff:/, ''); const normalizedClientIp = clientIp?.replace(/^::ffff:/, '');
if (serverConfig.webserver.banlist?.includes(clientIp)) { if (clientIp && serverConfig.webserver.banlist?.includes(clientIp)) {
ws.close(1008, 'Banned IP'); ws.close(1008, 'Banned IP');
return; return;
} }
if (clientIp.includes(',')) { if (clientIp && clientIp.includes(',')) {
clientIp = clientIp.split(',')[0].trim(); clientIp = clientIp.split(',')[0].trim();
} }
@@ -714,15 +707,15 @@ httpServer.on('upgrade', (request, socket, head) => {
}); });
}); });
} else if (request.url === '/audio') { } else if (request.url === '/audio') {
isPortOpen('localhost', (Number(serverConfig.webserver.webserverPort) + 10)).then((open) => { if (typeof audioServer?.handleAudioUpgrade === 'function') {
if (open) { audioServer.handleAudioUpgrade(request, socket, head, (ws) => {
proxy.ws(request, socket, head); audioServer.Server?.Server?.emit?.('connection', ws, request);
} else { });
logWarn(`Audio stream port ${(Number(serverConfig.webserver.webserverPort) + 10)} not yet open — skipping proxy connection.`); } else {
socket.end(); // close socket so client isn't left hanging logWarn('[Audio WebSocket] Audio server not ready — dropping client connection.');
} socket.destroy();
}); }
} else if (request.url === '/chat') { } else if (request.url === '/chat') {
sessionMiddleware(request, {}, () => { sessionMiddleware(request, {}, () => {
chatWss.handleUpgrade(request, socket, head, (ws) => { chatWss.handleUpgrade(request, socket, head, (ws) => {
chatWss.emit('connection', ws, request); chatWss.emit('connection', ws, request);
@@ -733,21 +726,21 @@ httpServer.on('upgrade', (request, socket, head) => {
rdsWss.handleUpgrade(request, socket, head, (ws) => { rdsWss.handleUpgrade(request, socket, head, (ws) => {
rdsWss.emit('connection', ws, request); rdsWss.emit('connection', ws, request);
const clientIp = request.headers['x-forwarded-for'] || request.connection.remoteAddress; const clientIp = request.headers['x-forwarded-for'] || request.connection.remoteAddress;
const userCommandHistory = {}; const userCommandHistory = {};
if (serverConfig.webserver.banlist?.includes(clientIp)) { if (serverConfig.webserver.banlist?.includes(clientIp)) {
ws.close(1008, 'Banned IP'); ws.close(1008, 'Banned IP');
return; return;
} }
// Anti-spam tracking for each client // Anti-spam tracking for each client
const userCommands = {}; const userCommands = {};
let lastWarn = { time: 0 }; let lastWarn = { time: 0 };
ws.on('message', function incoming(message) { ws.on('message', function incoming(message) {
// Anti-spam // Anti-spam
const command = helpers.antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, '5', 'rds'); const command = helpers.antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, '5', 'rds');
}); });
}); });
}); });

View File

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

View File

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