diff --git a/console.js b/console.js index a52f909..0d585bf 100644 --- a/console.js +++ b/console.js @@ -9,14 +9,16 @@ const getCurrentTime = () => { const MESSAGE_PREFIX = { DEBUG: "\x1b[36m[DEBUG]\x1b[0m", + ERROR: "\x1b[31m[ERROR]\x1b[0m", INFO: "\x1b[32m[INFO]\x1b[0m", WARN: "\x1b[33m[WARN]\x1b[0m", }; const logDebug = (...messages) => verboseMode ? console.log(getCurrentTime(), MESSAGE_PREFIX.DEBUG, ...messages) : ''; +const logError = (...messages) => console.log(getCurrentTime(), MESSAGE_PREFIX.ERROR, ...messages); const logInfo = (...messages) => console.log(getCurrentTime(), MESSAGE_PREFIX.INFO, ...messages); const logWarn = (...messages) => console.log(getCurrentTime(), MESSAGE_PREFIX.WARN, ...messages); module.exports = { - logInfo, logDebug, logWarn + logError, logDebug, logInfo, logWarn }; diff --git a/datahandler.js b/datahandler.js index a5fefb4..248bf63 100644 --- a/datahandler.js +++ b/datahandler.js @@ -1,10 +1,13 @@ /* Libraries / Imports */ +const fs = require('fs'); +const https = require('https'); const koffi = require('koffi'); const path = require('path'); const os = require('os'); const win32 = (os.platform() == "win32"); const unicode_type = (win32 ? 'int16_t' : 'int32_t'); const lib = koffi.load(path.join(__dirname, "librdsparser." + (win32 ? "dll" : "so"))); +const config = require('./userconfig'); koffi.proto('void callback_pi(void *rds, void *user_data)'); koffi.proto('void callback_pty(void *rds, void *user_data)'); @@ -299,4 +302,4 @@ function showOnlineUsers(currentUsers) { module.exports = { handleData, showOnlineUsers, dataToSend -}; +}; \ No newline at end of file diff --git a/index.js b/index.js index 866f063..292c2f5 100644 --- a/index.js +++ b/index.js @@ -9,26 +9,42 @@ const path = require('path'); const net = require('net'); const client = new net.Socket(); const crypto = require('crypto'); +const commandExists = require('command-exists-promise') const dataHandler = require('./datahandler'); const consoleCmd = require('./console'); const config = require('./userconfig'); +const audioStream = require('./stream/index.js'); -const { webServerHost, webServerPort, webServerName, xdrdServerHost, xdrdServerPort, xdrdPassword, qthLatitude, qthLongitude } = config; -const { logInfo, logDebug } = consoleCmd; +const { webServerHost, webServerPort, webServerName, audioPort, xdrdServerHost, xdrdServerPort, xdrdPassword, qthLatitude, qthLongitude } = config; +const { logDebug, logError, logInfo, logWarn } = consoleCmd; -let receivedSalt = ''; -let receivedPassword = false; let currentUsers = 0; +let streamEnabled = false; + +/* Audio Stream */ +commandExists('ffmpeg') + .then(exists => { + if (exists) { + logInfo("An existing installation of ffmpeg found, enabling audio stream."); + audioStream.enableAudioStream(); + streamEnabled = true; + } else { + logError("No ffmpeg installation found. Audio stream won't be available."); + } + }) + .catch(err => { + // Should never happen but better handle it just in case + }) /* webSocket handlers */ wss.on('connection', (ws, request) => { const clientIp = request.connection.remoteAddress; currentUsers++; dataHandler.showOnlineUsers(currentUsers); - consoleCmd.logInfo(`Web client \x1b[32mconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]`); + logInfo(`Web client \x1b[32mconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]`); ws.on('message', (message) => { - consoleCmd.logDebug('Received message from client:', message.toString()); + logDebug('Received message from client:', message.toString()); newFreq = message.toString() * 1000; client.write("T" + newFreq + '\n'); }); @@ -36,7 +52,7 @@ wss.on('connection', (ws, request) => { ws.on('close', (code, reason) => { currentUsers--; dataHandler.showOnlineUsers(currentUsers); - consoleCmd.logInfo(`Web client \x1b[31mdisconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]`); + logInfo(`Web client \x1b[31mdisconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]`); }); ws.on('error', console.error); @@ -61,7 +77,7 @@ function authenticateWithXdrd(client, salt, password) { // WebSocket client connection client.connect(xdrdServerPort, xdrdServerHost, () => { - consoleCmd.logInfo('Connection to xdrd established successfully.'); + logInfo('Connection to xdrd established successfully.'); const authFlags = { authMsg: false, @@ -82,7 +98,7 @@ client.connect(xdrdServerPort, xdrdServerHost, () => { } else { if (line.startsWith('a')) { authFlags.authMsg = true; - consoleCmd.logWarn('Authentication with xdrd failed. Is your password set correctly?'); + logWarn('Authentication with xdrd failed. Is your password set correctly?'); } else if (line.startsWith('o1,')) { authFlags.firstClient = true; } else if (line.startsWith('T') && line.length <= 7) { @@ -90,7 +106,7 @@ client.connect(xdrdServerPort, xdrdServerHost, () => { dataHandler.dataToSend.freq = freq.toFixed(3); } else if (line.startsWith('OK')) { authFlags.authMsg = true; - consoleCmd.logInfo('Authentication with xdrd successful.'); + logInfo('Authentication with xdrd successful.'); } if (authFlags.authMsg && authFlags.firstClient) { @@ -119,6 +135,24 @@ client.on('close', () => { console.log('Disconnected from xdrd'); }); +client.on('error', (err) => { + switch (true) { + case err.message.includes("ECONNRESET"): + logError("Connection to xdrd lost. Exiting..."); + break; + + case err.message.includes("ETIMEDOUT"): + logError("Connection to xdrd @ " + xdrdServerHost + ":" + xdrdServerPort + " timed out."); + break; + + default: + logError("Unhandled error: ", err.message); + } + + process.exit(1); +}); + + /* HTTP Server */ httpServer.on('upgrade', (request, socket, head) => { @@ -128,10 +162,10 @@ httpServer.on('upgrade', (request, socket, head) => { }); httpServer.listen(webServerPort, webServerHost, () => { - consoleCmd.logInfo(`Web server is running at \x1b[34mhttp://${webServerHost}:${webServerPort}\x1b[0m.`); + logInfo(`Web server is running at \x1b[34mhttp://${webServerHost}:${webServerPort}\x1b[0m.`); }); /* Static data are being sent through here on connection - these don't change when the server is running */ app.get('/static_data', (req, res) => { - res.json({ qthLatitude, qthLongitude, webServerName }); + res.json({ qthLatitude, qthLongitude, webServerName, audioPort, streamEnabled}); }); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ce01511..8e309fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,15 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "command-exists-promise": "^2.0.2", "express": "4.18.2", "http": "^0.0.1-security", "https": "1.0.0", "koffi": "2.7.2", "net": "1.0.2", "websocket": "1.0.34", - "ws": "8.14.2" + "wrtc": "^0.4.7", + "ws": "^8.14.2" } }, "node_modules/accepts": { @@ -91,6 +93,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/command-exists-promise": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/command-exists-promise/-/command-exists-promise-2.0.2.tgz", + "integrity": "sha512-T6PB6vdFrwnHXg/I0kivM3DqaCGZLjjYSOe0a5WgFKcz1sOnmOeIjnhQPXVXX3QjVbLyTJ85lJkX6lUpukTzaA==", + "engines": { + "node": ">=6" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -170,6 +180,16 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/domexception": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-1.0.1.tgz", + "integrity": "sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug==", + "deprecated": "Use your platform's native DOMException instead", + "optional": true, + "dependencies": { + "webidl-conversions": "^4.0.2" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -792,6 +812,12 @@ "node": ">= 0.8" } }, + "node_modules/webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "optional": true + }, "node_modules/websocket": { "version": "1.0.34", "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.34.tgz", @@ -808,6 +834,24 @@ "node": ">=4.0.0" } }, + "node_modules/wrtc": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/wrtc/-/wrtc-0.4.7.tgz", + "integrity": "sha512-P6Hn7VT4lfSH49HxLHcHhDq+aFf/jd9dPY7lDHeFhZ22N3858EKuwm2jmnlPzpsRGEPaoF6XwkcxY5SYnt4f/g==", + "bundleDependencies": [ + "node-pre-gyp" + ], + "hasInstallScript": true, + "dependencies": { + "node-pre-gyp": "^0.13.0" + }, + "engines": { + "node": "^8.11.2 || >=10.0.0" + }, + "optionalDependencies": { + "domexception": "^1.0.1" + } + }, "node_modules/ws": { "version": "8.14.2", "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", diff --git a/package.json b/package.json index 85626ed..6c5697c 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,14 @@ "author": "", "license": "ISC", "dependencies": { + "command-exists-promise": "^2.0.2", "express": "4.18.2", "http": "^0.0.1-security", "https": "1.0.0", "koffi": "2.7.2", "net": "1.0.2", "websocket": "1.0.34", - "ws": "8.14.2" + "wrtc": "^0.4.7", + "ws": "^8.14.2" } } diff --git a/stream/3las.server.js b/stream/3las.server.js new file mode 100644 index 0000000..14fa305 --- /dev/null +++ b/stream/3las.server.js @@ -0,0 +1,423 @@ +"use strict"; +/* + Stdin streamer is part of 3LAS (Low Latency Live Audio Streaming) + https://github.com/JoJoBond/3LAS +*/ +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 wrtc = require('wrtc'); +const Settings = JSON.parse((0, fs_1.readFileSync)('stream/settings.json', 'utf-8')); +const FFmpeg_command = (() => { + if (process.platform === 'win32') + return Settings.FallbackFFmpegPath; + else if (process.platform === 'linux') + return "ffmpeg"; +})(); +class RtcProvider { + constructor() { + this.RtcDistributePeer = new wrtc.RTCPeerConnection(Settings.RtcConfig); + this.RtcDistributePeer.addTransceiver('audio'); + this.RtcDistributePeer.ontrack = this.OnTrack.bind(this); + this.RtcDistributePeer.onicecandidate = this.OnIceCandidate_Distribute.bind(this); + this.RtcSourcePeer = new wrtc.RTCPeerConnection(Settings.RtcConfig); + this.RtcSourceMediaSource = new wrtc.nonstandard.RTCAudioSource(); + this.RtcSourceTrack = this.RtcSourceMediaSource.createTrack(); + this.RtcSourcePeer.addTrack(this.RtcSourceTrack); + this.RtcSourcePeer.onicecandidate = this.OnIceCandidate_Source.bind(this); + this.Init(); + } + Init() { + return __awaiter(this, void 0, void 0, function* () { + let offer = yield this.RtcSourcePeer.createOffer(); + yield this.RtcSourcePeer.setLocalDescription(new wrtc.RTCSessionDescription(offer)); + yield this.RtcDistributePeer.setRemoteDescription(offer); + let answer = yield this.RtcDistributePeer.createAnswer(); + yield this.RtcDistributePeer.setLocalDescription(new wrtc.RTCSessionDescription(answer)); + yield this.RtcSourcePeer.setRemoteDescription(new wrtc.RTCSessionDescription(answer)); + }); + } + OnTrack(event) { + this.RtcDistributeTrack = event.track; + } + OnIceCandidate_Distribute(e) { + if (!e.candidate) + return; + (() => __awaiter(this, void 0, void 0, function* () { return yield this.RtcSourcePeer.addIceCandidate(e.candidate); }))(); + } + OnIceCandidate_Source(e) { + if (!e.candidate) + return; + (() => __awaiter(this, void 0, void 0, function* () { return yield this.RtcDistributePeer.addIceCandidate(e.candidate); }))(); + } + InsertMediaData(data) { + if (!this.RtcSourceMediaSource) + return; + this.RtcSourceMediaSource.onData(data); + } + GetTrack() { + return this.RtcDistributeTrack; + } +} +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") { + (() => __awaiter(this, void 0, void 0, function* () { return yield this.RtcPeer.setRemoteDescription(new wrtc.RTCSessionDescription(request.data)); }))(); + } + else if (request.type == "webrtc") { + this.Server.SetWebRtc(this); + } + 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) { + } + if (this.RtcSender && this.RtcPeer) + this.RtcPeer.removeTrack(this.RtcSender); + if (this.RtcSender) + this.RtcSender = null; + if (this.RtcTrack) + this.RtcTrack = null; + if (this.RtcPeer) { + this.RtcPeer.close(); + delete this.RtcPeer; + this.RtcPeer = null; + } + } + 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); + } + StartRtc(track) { + return __awaiter(this, void 0, void 0, function* () { + this.RtcPeer = new wrtc.RTCPeerConnection(Settings.RtcConfig); + this.RtcTrack = track; + this.RtcSender = this.RtcPeer.addTrack(this.RtcTrack); + this.RtcPeer.onconnectionstatechange = this.OnConnectionStateChange.bind(this); + this.RtcPeer.onicecandidate = this.OnIceCandidate.bind(this); + let offer = yield this.RtcPeer.createOffer(); + yield this.RtcPeer.setLocalDescription(new wrtc.RTCSessionDescription(offer)); + this.SendText(JSON.stringify({ + "type": "offer", + "data": offer + })); + }); + } + OnConnectionStateChange(e) { + if (!this.RtcPeer) + return; + let state = this.RtcPeer.connectionState; + if (state != "new" && state != "connecting" && state != "connected") + this.OnError(null); + } + OnIceCandidate(e) { + if (e.candidate) { + this.SendText(JSON.stringify({ + "type": "candidate", + "data": e.candidate + })); + } + } +} +class StreamServer { + constructor(port, channels, sampleRate) { + this.Port = port; + this.Channels = channels; + this.SampleRate = sampleRate; + this.RtcProvider = new RtcProvider(); + this.Clients = new Set(); + this.RtcClients = 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({ + "port": this.Port, + "clientTracking": true, + "perMessageDeflate": false + }); + this.Server.on('connection', this.OnServerConnection.bind(this)); + 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.RtcProvider.InsertMediaData(data); + 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); + } + SetWebRtc(client) { + this.RtcClients.add(client); + client.StartRtc(this.RtcProvider.GetTrack()); + } + DestroyClient(client) { + this.FallbackClients["mp3"].delete(client); + this.FallbackClients["wav"].delete(client); + this.RtcClients.delete(client); + this.Clients.delete(client); + client.Destroy(); + } + GetStats() { + let rtc = this.RtcClients.size; + let fallback = { + "wav": (this.FallbackClients["wav"] ? this.FallbackClients["wav"].size : 0), + "mp3": (this.FallbackClients["mp3"] ? this.FallbackClients["mp3"].size : 0), + }; + let total = rtc; + for (let format in fallback) { + total += fallback[format]; + } + return { + "Total": total, + "Rtc": rtc, + "Fallback": fallback, + }; + } + static Create(options) { + if (!options["-port"]) + throw new Error("Port undefined. Please use -port to define the port."); + 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"]) + 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(options["-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", this.Server.SampleRate.toString(), + "-ac", this.Server.Channels.toString(), + "-i", "pipe:0", + "-c:a", "libmp3lame", + "-b:a", Settings.FallbackMp3Bitrate.toString() + "k", + "-ac", "1", + "-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", this.Server.SampleRate.toString(), + "-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]); + } + } +} +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 Server = StreamServer.Create(Options); +Server.Run(); +//# sourceMappingURL=3las.server.js.map \ No newline at end of file diff --git a/stream/index.js b/stream/index.js new file mode 100644 index 0000000..7511bf8 --- /dev/null +++ b/stream/index.js @@ -0,0 +1,43 @@ +const { spawn } = require('child_process'); +const config = require('../userconfig.js'); +const consoleCmd = require('../console.js'); + +function enableAudioStream() { + // Specify the command and its arguments + const command = 'ffmpeg'; + const flags = '-fflags +nobuffer+flush_packets -flags low_delay -rtbufsize 6192 -probesize 64 -audio_buffer_size 20'; + const codec = '-acodec pcm_s16le -ar 48000 -ac 1'; + const output = '-f s16le -fflags +nobuffer+flush_packets -packetsize 384 -flush_packets 1 -bufsize 960'; + // Combine all the settings for the ffmpeg command + const ffmpegCommand = `${flags} -f dshow -i audio="${config.audioDeviceName}" ${codec} ${output} pipe:1 | node stream/3las.server.js -port ${config.audioPort} -samplerate 48000 -channels 1`; + + consoleCmd.logInfo("Launching audio stream on port " + config.audioPort + "."); + // Spawn the child process + + if(config.audioDeviceName.length > 2) { + const childProcess = spawn(command, [ffmpegCommand], { shell: true }); + + // Handle the output of the child process (optional) + /*childProcess.stdout.on('data', (data) => { + console.log(`stdout: ${data}`); + }); + + childProcess.stderr.on('data', (data) => { + console.error(`stderr: ${data}`); + }); + + // Handle the child process exit event + childProcess.on('close', (code) => { + console.log(`Child process exited with code ${code}`); + }); + + // You can also listen for the 'error' event in case the process fails to start + childProcess.on('error', (err) => { + console.error(`Error starting child process: ${err}`); + });*/ + } +} + +module.exports = { + enableAudioStream +} diff --git a/stream/settings.json b/stream/settings.json new file mode 100644 index 0000000..4c41c9a --- /dev/null +++ b/stream/settings.json @@ -0,0 +1,9 @@ +{ + "RtcConfig" : null, + "FallbackFFmpegPath": "ffmpeg.exe", + "FallbackUseMp3": true, + "FallbackUseWav": true, + "FallbackMp3Bitrate": 128, + "FallbackWavSampleRate": 16000, + "AdminKey": "" +} \ No newline at end of file diff --git a/userconfig.js b/userconfig.js index c57deea..1e6c815 100644 --- a/userconfig.js +++ b/userconfig.js @@ -1,6 +1,9 @@ const webServerHost = '0.0.0.0'; // IP of the web server const webServerPort = 8080; // web server port -const webServerName = "Noobish's Server" // web server name (will be displayed in title, bookmarks...) +const webServerName = "Noobish's Server"; // web server name (will be displayed in title, bookmarks...) + +const audioDeviceName = "Microphone (High Definition Audio Device)"; // Audio device name in your OS +const audioPort = 8081; const xdrdServerHost = '127.0.0.1'; // xdrd server IP (if it's running on the same machine, use 127.0.0.1) const xdrdServerPort = 7373; // xdrd server port @@ -13,5 +16,5 @@ const verboseMode = false; // if true, console will display extra messages // DO NOT MODIFY ANYTHING BELOW THIS LINE module.exports = { - webServerHost, webServerPort, webServerName, xdrdServerHost, xdrdServerPort, xdrdPassword, qthLatitude, qthLongitude, verboseMode + webServerHost, webServerPort, webServerName, audioDeviceName, audioPort, xdrdServerHost, xdrdServerPort, xdrdPassword, qthLatitude, qthLongitude, verboseMode }; \ No newline at end of file diff --git a/web/css/3las/log_window.css b/web/css/3las/log_window.css new file mode 100644 index 0000000..2649d48 --- /dev/null +++ b/web/css/3las/log_window.css @@ -0,0 +1,66 @@ +p#logwindowbutton +{ + cursor: pointer; + font-size: 8pt; + -webkit-border-top-left-radius: 2pt; + -moz-border-radius-topleft: 2pt; + -o-border-radius-topleft: 2pt; + border-top-left-radius: 2pt; + + -webkit-border-top-right-radius: 2pt; + -moz-border-radius-topright: 2pt; + -o-border-radius-topright: 2pt; + border-top-right-radius: 2pt; + + -webkit-border-bottom-right-radius: 2pt; + -moz-border-radius-bottomright: 2pt; + -o-border-radius-bottomright: 2pt; + border-bottom-right-radius: 2pt; + + -webkit-border-bottom-left-radius: 2pt; + -moz-border-radius-bottomleft: 2pt; + -o-border-radius-bottomleft: 2pt; + border-bottom-left-radius: 2pt; + background: #111111; + color: #F0F0E1; + padding: 2pt; + width: 90pt; + + border: 1pt solid #777777; +} + +ul#logwindow +{ + font-family: monospace; + font-size: 8pt; + display: none; + -webkit-border-top-left-radius: 2pt; + -moz-border-radius-topleft: 2pt; + -o-border-radius-topleft: 2pt; + border-top-left-radius: 2pt; + + -webkit-border-top-right-radius: 2pt; + -moz-border-radius-topright: 2pt; + -o-border-radius-topright: 2pt; + border-top-right-radius: 2pt; + + -webkit-border-bottom-right-radius: 2pt; + -moz-border-radius-bottomright: 2pt; + -o-border-radius-bottomright: 2pt; + border-bottom-right-radius: 2pt; + + -webkit-border-bottom-left-radius: 2pt; + -moz-border-radius-bottomleft: 2pt; + -o-border-radius-bottomleft: 2pt; + border-bottom-left-radius: 2pt; + background: #CCCCCC; + padding: 2pt; + + border: 1pt solid #EEEEEE; +} + +ul#logwindow li +{ + margin: 0; + padding: 0; +} \ No newline at end of file diff --git a/web/css/3las/main.css b/web/css/3las/main.css new file mode 100644 index 0000000..b998134 --- /dev/null +++ b/web/css/3las/main.css @@ -0,0 +1,69 @@ +div#viewcontainer +{ + display: none; +} + +div.errormessage +{ + position: absolute; + display: none; + width: 200pt; + height: 150pt; + left: 0pt; + top: 0pt; + font-family: sans-serif; + font-size: 20pt; + font-weight: 200; + color: #EE0000; + background-color: #000000; +} + +div#chromesuggestion +{ + position: absolute; + display: none; + width: 200pt; + height: 150pt; + left: 0pt; + top: 0pt; + font-family: sans-serif; + font-size: 15pt; + font-weight: 200; + color: #EEEEEE; + background-color: #000000; +} + +div#chromesuggestion a,span +{ + text-decoration: none; + color: #888888; +} + +div#lightoff +{ + position: absolute; + width: 100%; + height: 100%; + left: 0pt; + top: 0pt; + z-index: 9999999; + background: black; + opacity: 0.9; + cursor: not-allowed; +} + +div#lightbutton +{ + background-image: url(../../images/3las/light.svg); + -webkit-background-size: 100% 100%; + -moz-background-size: 100% 100%; + -o-background-size: 100% 100%; + background-size: 100% 100%; + position: absolute; + width: 50pt; + height: 50pt; + right: 0; + top: 0; + z-index: 99999999; + cursor: pointer; +} \ No newline at end of file diff --git a/web/css/3las/player_controls.css b/web/css/3las/player_controls.css new file mode 100644 index 0000000..fc9a7ca --- /dev/null +++ b/web/css/3las/player_controls.css @@ -0,0 +1,274 @@ +div#audioplayer +{ + position: relative; + width: 180pt; + height: 100pt; + + background: #4c4e5a; + background: -moz-linear-gradient(top, #4c4e5a 0%, #2c2d33 100%); + background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#4c4e5a), color-stop(100%,#2c2d33)); + background: -webkit-linear-gradient(top, #4c4e5a 0%,#2c2d33 100%); + background: -o-linear-gradient(top, #4c4e5a 0%,#2c2d33 100%); + background: -ms-linear-gradient(top, #4c4e5a 0%,#2c2d33 100%); + background: linear-gradient(to bottom, #4c4e5a 0%,#2c2d33 100%); + + -webkit-border-top-left-radius: 2pt; + -moz-border-radius-topleft: 2pt; + -o-border-radius-topleft: 2pt; + border-top-left-radius: 2pt; + + -webkit-border-top-right-radius: 2pt; + -moz-border-radius-topright: 2pt; + -o-border-radius-topright: 2pt; + border-top-right-radius: 2pt; + + -webkit-border-bottom-right-radius: 2pt; + -moz-border-radius-bottomright: 2pt; + -o-border-radius-bottomright: 2pt; + border-bottom-right-radius: 2pt; + + -webkit-border-bottom-left-radius: 2pt; + -moz-border-radius-bottomleft: 2pt; + -o-border-radius-bottomleft: 2pt; + border-bottom-left-radius: 2pt; + + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +div#audioplayer div#volumebar +{ + position: absolute; + visibility: hidden; + top: 65pt; + left: 15pt; + width: 150pt; + height: 20pt; +} + +div#audioplayer div#activityindicator +{ + position: absolute; + top: 5pt; + left: 5pt; + width: 20pt; + height: 20pt; + -webkit-border-top-left-radius: 10pt; + -moz-border-radius-topleft: 10pt; + -o-border-radius-topleft: 10pt; + border-top-left-radius: 10pt; + + -webkit-border-top-right-radius: 10pt; + -moz-border-radius-topright: 10pt; + -o-border-radius-topright: 10pt; + border-top-right-radius: 10pt; + + -webkit-border-bottom-right-radius: 10pt; + -moz-border-radius-bottomright: 10pt; + -o-border-radius-bottomright: 10pt; + border-bottom-right-radius: 10pt; + + -webkit-border-bottom-left-radius: 10pt; + -moz-border-radius-bottomleft: 10pt; + -o-border-radius-bottomleft: 10pt; + border-bottom-left-radius: 10pt; + + + -webkit-box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3) inset, 1px 1px 1px rgba(255, 255, 255, 0.25); + -moz-box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3) inset, 1px 1px 1px rgba(255, 255, 255, 0.25); + -o-box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3) inset, 1px 1px 1px rgba(255, 255, 255, 0.25); + box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3) inset, 1px 1px 1px rgba(255, 255, 255, 0.25); + +} + +div#audioplayer div#activityindicator div#redlighton +{ + position: absolute; + visibility: hidden; + top: 1pt; + left: 1pt; + width: 19pt; + height: 19pt; + background-image: url(../../images/3las/red_light_on.svg); + -webkit-background-size: 100% 100%; + -moz-background-size: 100% 100%; + -o-background-size: 100% 100%; + background-size: 100% 100%; +} + +div#audioplayer div#activityindicator div#redlightoff +{ + position: absolute; + top: 1pt; + left: 1pt; + width: 19pt; + height: 19pt; + background-image: url(../../images/3las/red_light_off.svg); + -webkit-background-size: 100% 100%; + -moz-background-size: 100% 100%; + -o-background-size: 100% 100%; + background-size: 100% 100%; +} + +div#audioplayer div#volumebar * +{ + margin: 0px; + padding: 0px; + border: medium none; + outline: medium none; +} + +div#audioplayer div#volumebar div#totalvolume +{ + position: absolute; + top: 3pt; + left: 0; + width: 100%; + height: 14pt; + + + -webkit-box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3) inset, 1px 1px 1px rgba(255, 255, 255, 0.25); + -moz-box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3) inset, 1px 1px 1px rgba(255, 255, 255, 0.25); + -o-box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3) inset, 1px 1px 1px rgba(255, 255, 255, 0.25); + box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3) inset, 1px 1px 1px rgba(255, 255, 255, 0.25); + + background: none repeat scroll 0% 0% rgb(33, 34, 39); + + + -webkit-border-top-left-radius: 7pt; + -moz-border-radius-topleft: 7pt; + -o-border-radius-topleft: 7pt; + border-top-left-radius: 7pt; + + -webkit-border-top-right-radius: 7pt; + -moz-border-radius-topright: 7pt; + -o-border-radius-topright: 7pt; + border-top-right-radius: 7pt; + + -webkit-border-bottom-right-radius: 7pt; + -moz-border-radius-bottomright: 7pt; + -o-border-radius-bottomright: 7pt; + border-bottom-right-radius: 7pt; + + -webkit-border-bottom-left-radius: 7pt; + -moz-border-radius-bottomleft: 7pt; + -o-border-radius-bottomleft: 7pt; + border-bottom-left-radius: 7pt; +} + +div#audioplayer div#volumebar div#currentvolume +{ + position: absolute; + top: 4pt; + left: 1pt; + width: 75pt; + height: 12pt; + background-image: url(../../images/3las/bar.svg); + -webkit-border-top-left-radius: 6pt; + -moz-border-radius-topleft: 6pt; + -o-border-radius-topleft: 6pt; + border-top-left-radius: 6pt; + -webkit-border-bottom-left-radius: 6pt; + -moz-border-radius-bottomleft: 6pt; + -o-border-radius-bottomleft: 6pt; + border-bottom-left-radius: 6pt; +} + +div#audioplayer div#volumebar div#volumeknob +{ + position: absolute; + cursor: pointer; + top: 0; + left: 75pt; + width: 20pt; + height: 20pt; + background-image: url(../../images/3las/knob.svg); + -webkit-background-size: 100% 100%; + -moz-background-size: 100% 100%; + -o-background-size: 100% 100%; + background-size: 100% 100%; + margin-left: -10pt; +} + +div#audioplayer div#playbuttonoverlay * +{ + margin: 0px; + padding: 0px; + border: medium none; + outline: medium none; +} + +div#audioplayer div#playbuttonoverlay +{ + position: absolute; + top: 15pt; + left: 15pt; + width: 150pt; + height: 70pt; +} + +div#audioplayer div#playbuttonoverlay div#playbutton +{ + position: absolute; + cursor: pointer; + top: 0pt; + left: 40pt; + width: 70pt; + height: 70pt; + background-image: url(../../images/3las/play.svg); + -webkit-background-size: 100% 100%; + -moz-background-size: 100% 100%; + -o-background-size: 100% 100%; + background-size: 100% 100%; +} + +div#audioplayer div#controlbar * +{ + margin: 0px; + padding: 0px; + border: medium none; + outline: medium none; +} + +div#audioplayer div#controlbar +{ + position: absolute; + visibility: hidden; + top: 15pt; + left: 15pt; + width: 150pt; + height: 50pt; +} + +div#audioplayer div#controlbar div#mutebutton +{ + position: absolute; + cursor: pointer; + top: 0pt; + left: 51.5pt; + width: 47pt; + height: 40pt; + background-image: url(../../images/3las/mute.svg); + -webkit-background-size: 100% 100%; + -moz-background-size: 100% 100%; + -o-background-size: 100% 100%; + background-size: 100% 100%; +} + +div#audioplayer div#controlbar div#unmutebutton +{ + visibility: hidden; + position: absolute; + cursor: pointer; + top: 0pt; + left: 51.5pt; + width: 47pt; + height: 40pt; + background-image: url(../../images/3las/unmute.svg); + -webkit-background-size: 100% 100%; + -moz-background-size: 100% 100%; + -o-background-size: 100% 100%; + background-size: 100% 100%; +} \ No newline at end of file diff --git a/web/css/buttons.css b/web/css/buttons.css index 56ba241..5ba65e9 100644 --- a/web/css/buttons.css +++ b/web/css/buttons.css @@ -1,3 +1,16 @@ +.play-button { + width: 100%; + height: 100%; + border: 0; + border-radius: 30px; + transition: 0.3s ease-in-out background-color; + background-color: var(--color-4); + cursor: pointer; +} + +.play-button:hover { + background-color: var(--color-main-bright); +} #tune-buttons input[type="text"] { width: 50%; @@ -73,7 +86,7 @@ input[type="range"]::-webkit-slider-thumb { /* creating a custom design */ height: 48px; width: 48px; - background-color: #fff; + background-color: var(--color-4); border-radius: 10px; border: 2px solid var(--color-4); /* slider progress trick */ diff --git a/web/css/helpers.css b/web/css/helpers.css index aea7047..3370115 100644 --- a/web/css/helpers.css +++ b/web/css/helpers.css @@ -26,6 +26,10 @@ color: var(--color-4); } +.br-5 { + border-radius: 5px; +} + .flex-container { display: flex; } @@ -36,11 +40,24 @@ align-items: center; } +.flex-left { + display: flex; + align-items: center; +} + +.hover-brighten { + transition: 0.3s ease-in-out background-color; +} + .hover-brighten:hover { cursor: pointer; background-color: var(--color-2); } +.text-left { + text-align: left; +} + .text-big { font-size: 60px; font-weight: 300; @@ -68,6 +85,22 @@ text-transform: uppercase; } +.top-10 { + margin-top: 10px; +} + +.bottom-20 { + margin-bottom: 20px; +} + +.bottom-50 { + margin-bottom: 50px; +} + +.p-10 { + padding: 10px; +} + @media only screen and (max-width: 960px) { .text-medium-big { font-size: 32px; diff --git a/web/css/main.css b/web/css/main.css index 66120ae..b02ad56 100644 --- a/web/css/main.css +++ b/web/css/main.css @@ -44,6 +44,7 @@ body { color: white; background-color: var(--color-main); transition: 0.3s ease-in-out background-color; + margin: 0 auto; } #wrapper { diff --git a/web/css/modal.css b/web/css/modal.css index 6f718a6..cfcf3cc 100644 --- a/web/css/modal.css +++ b/web/css/modal.css @@ -29,10 +29,6 @@ min-width: 500px; } -.modal-content p { - margin: 0; -} - .modal-title { font-size: 20px; position: absolute; diff --git a/web/images/3las/bar.svg b/web/images/3las/bar.svg new file mode 100644 index 0000000..2400b04 --- /dev/null +++ b/web/images/3las/bar.svg @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/web/images/3las/knob.svg b/web/images/3las/knob.svg new file mode 100644 index 0000000..dd6d311 --- /dev/null +++ b/web/images/3las/knob.svg @@ -0,0 +1,869 @@ + + + + \ No newline at end of file diff --git a/web/images/3las/light.svg b/web/images/3las/light.svg new file mode 100644 index 0000000..07b46f3 --- /dev/null +++ b/web/images/3las/light.svg @@ -0,0 +1,120 @@ + + diff --git a/web/images/3las/mute.svg b/web/images/3las/mute.svg new file mode 100644 index 0000000..42b170a --- /dev/null +++ b/web/images/3las/mute.svg @@ -0,0 +1,902 @@ + + + + diff --git a/web/images/3las/pause.svg b/web/images/3las/pause.svg new file mode 100644 index 0000000..28bf8c4 --- /dev/null +++ b/web/images/3las/pause.svg @@ -0,0 +1,331 @@ + + + + diff --git a/web/images/3las/play.svg b/web/images/3las/play.svg new file mode 100644 index 0000000..845539a --- /dev/null +++ b/web/images/3las/play.svg @@ -0,0 +1,302 @@ + + + + diff --git a/web/images/3las/red_light_off.svg b/web/images/3las/red_light_off.svg new file mode 100644 index 0000000..c4bde2b --- /dev/null +++ b/web/images/3las/red_light_off.svg @@ -0,0 +1,858 @@ + + + + diff --git a/web/images/3las/red_light_on.svg b/web/images/3las/red_light_on.svg new file mode 100644 index 0000000..74a3430 --- /dev/null +++ b/web/images/3las/red_light_on.svg @@ -0,0 +1,830 @@ + + + + diff --git a/web/images/3las/repeat.svg b/web/images/3las/repeat.svg new file mode 100644 index 0000000..51ee92e --- /dev/null +++ b/web/images/3las/repeat.svg @@ -0,0 +1,136 @@ + + \ No newline at end of file diff --git a/web/images/3las/unmute.svg b/web/images/3las/unmute.svg new file mode 100644 index 0000000..0b4dd9d --- /dev/null +++ b/web/images/3las/unmute.svg @@ -0,0 +1,847 @@ + + + + diff --git a/web/images/bmc.png b/web/images/bmc.png new file mode 100644 index 0000000..4c207cc Binary files /dev/null and b/web/images/bmc.png differ diff --git a/web/index.html b/web/index.html index 0a9cba3..11514d7 100644 --- a/web/index.html +++ b/web/index.html @@ -8,11 +8,44 @@ + + + + + + + + + + + + + +
+FM-DX WebServer uses librdsparser by Konrad Kosmatka.
+FM-DX WebServer by Noobish & the OpenRadio community.
+This app uses librdsparser by Konrad Kosmatka.