From 028cd2e587098b51e62fbad5922168ab689faa60 Mon Sep 17 00:00:00 2001 From: KubaPro010 Date: Mon, 23 Feb 2026 17:48:35 +0100 Subject: [PATCH] Re-design the audio engine --- .gitignore | 2 +- package-lock.json | 42 +--- server/index.js | 39 +-- server/stream/3las.server.js | 381 ---------------------------- server/stream/index.js | 476 +++++++++-------------------------- server/stream/ws.js | 40 +++ 6 files changed, 166 insertions(+), 814 deletions(-) delete mode 100644 server/stream/3las.server.js create mode 100644 server/stream/ws.js diff --git a/.gitignore b/.gitignore index 8c9266d..25b11ec 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ node_modules/ /*.json /serverlog.txt /web/js/plugins/ -/libraries/** +/libraries/ /plugins/* !/plugins/example/frontend.js !/plugins/example.js diff --git a/package-lock.json b/package-lock.json index 2caec72..20fcdd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/server/index.js b/server/index.js index bf3b75d..0f23297 100644 --- a/server/index.js +++ b/server/index.js @@ -16,9 +16,9 @@ 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'); @@ -90,8 +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'); @@ -617,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') { @@ -655,14 +631,11 @@ 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' && serverConfig.webserver.chatEnabled === true) { sessionMiddleware(request, {}, () => { chatWss.handleUpgrade(request, socket, head, (ws) => { diff --git a/server/stream/3las.server.js b/server/stream/3las.server.js deleted file mode 100644 index 33797dc..0000000 --- a/server/stream/3las.server.js +++ /dev/null @@ -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 diff --git a/server/stream/index.js b/server/stream/index.js index b988e4d..360e3df 100644 --- a/server/stream/index.js +++ b/server/stream/index.js @@ -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,144 @@ 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 || 48000) + + 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", "+nobuffer+flush_packets", + "-flags", "low_delay", + "-rtbufsize", "32", + "-probesize", "32", + + ...inputArgs, + + "-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 → Server.StdIn` + ); - 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(); - }); - - 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); - } - }); + 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}`); } - } - 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}`); - } - }); - } + logWarn(`${consoleLogTitle} Restarting FFmpeg in 5 seconds...`); + setTimeout(launchFFmpeg, 5000); + }); } + + 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; \ No newline at end of file diff --git a/server/stream/ws.js b/server/stream/ws.js new file mode 100644 index 0000000..2ab0f03 --- /dev/null +++ b/server/stream/ws.js @@ -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 }; \ No newline at end of file