From 8ce697a9606c757628384c6dff57c99432f7b0ed Mon Sep 17 00:00:00 2001 From: NoobishSVK Date: Sat, 27 Jan 2024 11:46:52 +0100 Subject: [PATCH] 3LAS implementation --- console.js | 4 +- datahandler.js | 5 +- index.js | 58 +- package-lock.json | 46 +- package.json | 4 +- stream/3las.server.js | 423 ++++++++ stream/index.js | 43 + stream/settings.json | 9 + userconfig.js | 7 +- web/css/3las/log_window.css | 66 ++ web/css/3las/main.css | 69 ++ web/css/3las/player_controls.css | 274 ++++++ web/css/buttons.css | 15 +- web/css/helpers.css | 33 + web/css/main.css | 1 + web/css/modal.css | 4 - web/images/3las/bar.svg | 6 + web/images/3las/knob.svg | 869 +++++++++++++++++ web/images/3las/light.svg | 120 +++ web/images/3las/mute.svg | 902 ++++++++++++++++++ web/images/3las/pause.svg | 331 +++++++ web/images/3las/play.svg | 302 ++++++ web/images/3las/red_light_off.svg | 858 +++++++++++++++++ web/images/3las/red_light_on.svg | 830 ++++++++++++++++ web/images/3las/repeat.svg | 136 +++ web/images/3las/unmute.svg | 847 ++++++++++++++++ web/images/bmc.png | Bin 0 -> 18974 bytes web/index.html | 64 +- web/js/3las/3las.js | 135 +++ web/js/3las/3las.webrtc.js | 177 ++++ web/js/3las/fallback/3las.fallback.js | 167 ++++ web/js/3las/fallback/3las.formatreader.js | 193 ++++ web/js/3las/fallback/3las.liveaudioplayer.js | 147 +++ .../formats/3las.formatreader.mpeg.js | 284 ++++++ .../fallback/formats/3las.formatreader.wav.js | 223 +++++ web/js/3las/main.js | 49 + web/js/3las/util/3las.helpers.js | 130 +++ web/js/3las/util/3las.logging.js | 26 + web/js/3las/util/3las.websocketclient.js | 78 ++ web/js/main.js | 32 +- web/js/{themes.js => settings.js} | 21 + web/js/webserver.js | 2 +- 42 files changed, 7950 insertions(+), 40 deletions(-) create mode 100644 stream/3las.server.js create mode 100644 stream/index.js create mode 100644 stream/settings.json create mode 100644 web/css/3las/log_window.css create mode 100644 web/css/3las/main.css create mode 100644 web/css/3las/player_controls.css create mode 100644 web/images/3las/bar.svg create mode 100644 web/images/3las/knob.svg create mode 100644 web/images/3las/light.svg create mode 100644 web/images/3las/mute.svg create mode 100644 web/images/3las/pause.svg create mode 100644 web/images/3las/play.svg create mode 100644 web/images/3las/red_light_off.svg create mode 100644 web/images/3las/red_light_on.svg create mode 100644 web/images/3las/repeat.svg create mode 100644 web/images/3las/unmute.svg create mode 100644 web/images/bmc.png create mode 100644 web/js/3las/3las.js create mode 100644 web/js/3las/3las.webrtc.js create mode 100644 web/js/3las/fallback/3las.fallback.js create mode 100644 web/js/3las/fallback/3las.formatreader.js create mode 100644 web/js/3las/fallback/3las.liveaudioplayer.js create mode 100644 web/js/3las/fallback/formats/3las.formatreader.mpeg.js create mode 100644 web/js/3las/fallback/formats/3las.formatreader.wav.js create mode 100644 web/js/3las/main.js create mode 100644 web/js/3las/util/3las.helpers.js create mode 100644 web/js/3las/util/3las.logging.js create mode 100644 web/js/3las/util/3las.websocketclient.js rename web/js/{themes.js => settings.js} (65%) 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 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + Lapo Calamandrei + + + + + Pause + + + playback + pause + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + Lapo Calamandrei + + + + + Play + + + play + playback + start + begin + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Layer 1 + + + \ 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 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/images/bmc.png b/web/images/bmc.png new file mode 100644 index 0000000000000000000000000000000000000000..4c207cc5d9ae2595f4c3704b5274ffe84d368d98 GIT binary patch literal 18974 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 F0RTIUMO6R* literal 0 HcmV?d00001 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 @@ + + + + + + + + + + + + + + +
- +
@@ -34,7 +67,6 @@
-

PI CODE

@@ -58,24 +90,25 @@
- +
- + - +
-
@@ -134,17 +167,24 @@
-
+
0
-

FM-DX WebServer uses librdsparser by Konrad Kosmatka.

+
+  Join our Discord community! +
+
+  Support the developer! +
+

FM-DX WebServer by Noobish & the OpenRadio community.

+

This app uses librdsparser by Konrad Kosmatka.

diff --git a/web/js/3las/3las.js b/web/js/3las/3las.js new file mode 100644 index 0000000..057e7e8 --- /dev/null +++ b/web/js/3las/3las.js @@ -0,0 +1,135 @@ +var _3LAS_Settings = /** @class */ (function () { + function _3LAS_Settings() { + this.SocketHost = document.location.hostname ? document.location.hostname : "127.0.0.1"; + this.SocketPort = localStorage.getItem('audioPort') ? localStorage.getItem('audioPort') : 8081; + this.SocketPath = "/audio"; + this.WebRTC = new WebRTC_Settings(); + this.Fallback = new Fallback_Settings(); + } + return _3LAS_Settings; +}()); +var _3LAS = /** @class */ (function () { + function _3LAS(logger, settings) { + this.Logger = logger; + if (!this.Logger) { + this.Logger = new Logging(null, null); + } + this.Settings = settings; + try { + this.WebRTC = new WebRTC(this.Logger, this.Settings.WebRTC); + this.WebRTC.ActivityCallback = this.OnActivity.bind(this); + this.WebRTC.DisconnectCallback = this.OnSocketDisconnect.bind(this); + } + catch (_a) { + this.WebRTC = null; + } + if (this.WebRTC == null || this.WebRTC !== null) { + try { + this.Fallback = new Fallback(this.Logger, this.Settings.Fallback); + this.Fallback.ActivityCallback = this.OnActivity.bind(this); + } + catch (_b) { + this.Fallback = null; + } + } + if (this.WebRTC == null && this.Fallback == null) { + this.Logger.Log('3LAS: Browser does not support either media handling methods.'); + throw new Error(); + } + if (isAndroid) { + this.WakeLock = new WakeLock(this.Logger); + } + } + Object.defineProperty(_3LAS.prototype, "Volume", { + get: function () { + if (this.WebRTC) + return this.WebRTC.Volume; + else + return this.Fallback.Volume; + }, + set: function (value) { + if (this.WebRTC) + this.WebRTC.Volume = value; + else + this.Fallback.Volume = value; + }, + enumerable: false, + configurable: true + }); + _3LAS.prototype.CanChangeVolume = function () { + if (this.WebRTC) + return this.WebRTC.CanChangeVolume(); + else + return true; + }; + _3LAS.prototype.Start = function () { + this.ConnectivityFlag = false; + // This is stupid, but required for iOS/iPadOS... thanks Apple :( + if (this.Settings && this.Settings.WebRTC && this.Settings.WebRTC.AudioTag) + this.Settings.WebRTC.AudioTag.play(); + // This is stupid, but required for Android.... thanks Google :( + if (this.WakeLock) + this.WakeLock.Begin(); + try { + this.WebSocket = new WebSocketClient(this.Logger, 'ws://' + this.Settings.SocketHost + ':' + this.Settings.SocketPort.toString() + this.Settings.SocketPath, this.OnSocketError.bind(this), this.OnSocketConnect.bind(this), this.OnSocketDataReady.bind(this), this.OnSocketDisconnect.bind(this)); + this.Logger.Log("Init of WebSocketClient succeeded"); + this.Logger.Log("Trying to connect to server."); + } + catch (e) { + this.Logger.Log("Init of WebSocketClient failed: " + e); + throw new Error(); + } + }; + _3LAS.prototype.OnActivity = function () { + if (this.ActivityCallback) + this.ActivityCallback(); + if (!this.ConnectivityFlag) { + this.ConnectivityFlag = true; + if (this.ConnectivityCallback) + this.ConnectivityCallback(true); + } + }; + // Callback function from socket connection + _3LAS.prototype.OnSocketError = function (message) { + this.Logger.Log("Network error: " + message); + if (this.WebRTC) + this.WebRTC.OnSocketError(message); + else + this.Fallback.OnSocketError(message); + }; + _3LAS.prototype.OnSocketConnect = function () { + this.Logger.Log("Established connection with server."); + if (this.WebRTC) + this.WebRTC.OnSocketConnect(); + else + this.Fallback.OnSocketConnect(); + if (this.WebRTC) + this.WebRTC.Init(this.WebSocket); + else + this.Fallback.Init(this.WebSocket); + }; + _3LAS.prototype.OnSocketDisconnect = function () { + this.Logger.Log("Lost connection to server."); + if (this.WebRTC) + this.WebRTC.OnSocketDisconnect(); + else + this.Fallback.OnSocketDisconnect(); + if (this.WebRTC) + this.WebRTC.Reset(); + else + this.Fallback.Reset(); + if (this.ConnectivityFlag) { + this.ConnectivityFlag = false; + if (this.ConnectivityCallback) + this.ConnectivityCallback(false); + } + this.Start(); + }; + _3LAS.prototype.OnSocketDataReady = function (data) { + if (this.WebRTC) + this.WebRTC.OnSocketDataReady(data); + else + this.Fallback.OnSocketDataReady(data); + }; + return _3LAS; +}()); \ No newline at end of file diff --git a/web/js/3las/3las.webrtc.js b/web/js/3las/3las.webrtc.js new file mode 100644 index 0000000..b84b327 --- /dev/null +++ b/web/js/3las/3las.webrtc.js @@ -0,0 +1,177 @@ +/* + RTC live audio is part of 3LAS (Low Latency Live Audio Streaming) + https://github.com/JoJoBond/3LAS +*/ +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()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g; + return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (_) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +var WebRTC_Settings = /** @class */ (function () { + function WebRTC_Settings() { + } + return WebRTC_Settings; +}()); +var WebRTC = /** @class */ (function () { + function WebRTC(logger, settings) { + this.Logger = logger; + if (!this.Logger) { + this.Logger = new Logging(null, null); + } + this.AudioTag = settings.AudioTag; + // Create RTC peer connection + if (typeof RTCPeerConnection !== "undefined") + this.RtcPeer = new RTCPeerConnection(settings.RtcConfig); + else if (typeof webkitRTCPeerConnection !== "undefined") + this.RtcPeer = new webkitRTCPeerConnection(settings.RtcConfig); + else if (typeof mozRTCPeerConnection !== "undefined") + this.RtcPeer = new mozRTCPeerConnection(settings.RtcConfig); + else { + this.Logger.Log('3LAS: Browser does not support "WebRTC".'); + throw new Error(); + } + this.Logger.Log("Using WebRTC"); + this.RtcPeer.addTransceiver('audio'); + this.RtcPeer.ontrack = this.OnTrack.bind(this); + this.RtcPeer.oniceconnectionstatechange = this.OnConnectionStateChange.bind(this); + } + Object.defineProperty(WebRTC.prototype, "Volume", { + get: function () { + if (!this.CanChangeVolume()) { + if (this.AudioTag.muted == true) + return 0.0; + else + return 1.0; + } + return this.AudioTag.volume; + }, + set: function (value) { + if (!this.CanChangeVolume()) { + if (value <= 0.0) + this.AudioTag.muted = true; + else + this.AudioTag.muted = false; + return; + } + this.AudioTag.volume = value; + }, + enumerable: false, + configurable: true + }); + WebRTC.prototype.CanChangeVolume = function () { + return !(isIOS || isIPadOS); + }; + WebRTC.prototype.Init = function (webSocket) { + this.WebSocket = webSocket; + this.WebSocket.Send(JSON.stringify({ + "type": "webrtc", + "data": null + })); + this.ActivityTimer = setInterval(this.OnActivityTimerTick.bind(this), 1000); + }; + WebRTC.prototype.OnActivityTimerTick = function () { + if ((this.RtcPeer.iceConnectionState == "connected" || this.RtcPeer.iceConnectionState == "completed") && this.ActivityCallback) + this.ActivityCallback(); + }; + WebRTC.prototype.OnConnectionStateChange = function () { + if ((this.RtcPeer.iceConnectionState == "closed" || + this.RtcPeer.iceConnectionState == "disconnected" || + this.RtcPeer.iceConnectionState == "failed") && this.DisconnectCallback) + this.DisconnectCallback(); + }; + WebRTC.prototype.OnTrack = function (event) { + if (event.streams != null && event.streams.length > 0) + this.AudioTag.srcObject = event.streams[0]; + else if (event.track != null) + this.AudioTag.srcObject = new MediaStream([event.track]); + this.AudioTag.play(); + }; + WebRTC.prototype.OnSocketError = function (message) { + }; + WebRTC.prototype.OnSocketConnect = function () { + }; + WebRTC.prototype.OnSocketDisconnect = function () { + }; + WebRTC.prototype.Reset = function () { + if (this.ActivityTimer) { + clearInterval(this.ActivityTimer); + this.ActivityTimer = 0; + } + if (this.RtcPeer) { + this.RtcPeer.close(); + delete this.RtcPeer; + this.RtcPeer = null; + } + this.WebSocket = null; + }; + WebRTC.prototype.OnSocketDataReady = function (data) { + return __awaiter(this, void 0, void 0, function () { + var message, answer; + var _this = this; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + message = JSON.parse(data.toString()); + if (!(message.type == "offer")) return [3 /*break*/, 4]; + return [4 /*yield*/, this.RtcPeer.setRemoteDescription(new RTCSessionDescription(message.data))]; + case 1: + _a.sent(); + return [4 /*yield*/, this.RtcPeer.createAnswer()]; + case 2: + answer = _a.sent(); + return [4 /*yield*/, this.RtcPeer.setLocalDescription(new RTCSessionDescription(answer))]; + case 3: + _a.sent(); + this.WebSocket.Send(JSON.stringify({ + "type": "answer", + "data": answer + })); + return [3 /*break*/, 5]; + case 4: + if (message.type == "candidate") { + (function () { return __awaiter(_this, void 0, void 0, function () { return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, this.RtcPeer.addIceCandidate(message.data)]; + case 1: return [2 /*return*/, _a.sent()]; + } + }); }); })(); + } + _a.label = 5; + case 5: return [2 /*return*/]; + } + }); + }); + }; + return WebRTC; +}()); \ No newline at end of file diff --git a/web/js/3las/fallback/3las.fallback.js b/web/js/3las/fallback/3las.fallback.js new file mode 100644 index 0000000..c272db5 --- /dev/null +++ b/web/js/3las/fallback/3las.fallback.js @@ -0,0 +1,167 @@ +/* + Socket fallback is part of 3LAS (Low Latency Live Audio Streaming) + https://github.com/JoJoBond/3LAS +*/ +var Fallback_Settings = /** @class */ (function () { + function Fallback_Settings() { + this.Formats = [ + { "Mime": "audio/mpeg", "Name": "mp3" }, + { "Mime": "audio/wave", "Name": "wav" } + ]; + this.MaxVolume = 1.0; + this.AutoCorrectSpeed = false; + this.InitialBufferLength = 1.0 / 3.0; + } + return Fallback_Settings; +}()); +var Fallback = /** @class */ (function () { + function Fallback(logger, settings) { + this.Logger = logger; + if (!this.Logger) { + this.Logger = new Logging(null, null); + } + // Create audio context + if (typeof AudioContext !== "undefined") + this.Audio = new AudioContext(); + else if (typeof webkitAudioContext !== "undefined") + this.Audio = new webkitAudioContext(); + else if (typeof mozAudioContext !== "undefined") + this.Audio = new mozAudioContext(); + else { + this.Logger.Log('3LAS: Browser does not support "AudioContext".'); + throw new Error(); + } + this.Settings = settings; + this.Logger.Log("Detected: " + + (OSName == "MacOSX" ? "Mac OSX" : (OSName == "Unknown" ? "Unknown OS" : OSName)) + ", " + + (BrowserName == "IE" ? "Internet Explorer" : (BrowserName == "NativeChrome" ? "Chrome legacy" : (BrowserName == "Unknown" ? "Unknown Browser" : BrowserName)))); + this.SelectedFormatMime = ""; + this.SelectedFormatName = ""; + for (var i = 0; i < this.Settings.Formats.length; i++) { + if (!AudioFormatReader.CanDecodeTypes([this.Settings.Formats[i].Mime])) + continue; + this.SelectedFormatMime = this.Settings.Formats[i].Mime; + this.SelectedFormatName = this.Settings.Formats[i].Name; + break; + } + if (this.SelectedFormatMime == "" || this.SelectedFormatName == "") { + this.Logger.Log("None of the available MIME types are supported."); + throw new Error(); + } + this.Logger.Log("Using websocket fallback with MIME: " + this.SelectedFormatMime); + try { + this.Player = new LiveAudioPlayer(this.Audio, this.Logger, this.Settings.MaxVolume, this.Settings.InitialBufferLength, this.Settings.AutoCorrectSpeed); + this.Logger.Log("Init of LiveAudioPlayer succeeded"); + } + catch (e) { + this.Logger.Log("Init of LiveAudioPlayer failed: " + e); + throw new Error(); + } + try { + this.FormatReader = AudioFormatReader.Create(this.SelectedFormatMime, this.Audio, this.Logger, this.OnReaderError.bind(this), this.Player.CheckBeforeDecode, this.OnReaderDataReady.bind(this), AudioFormatReader.DefaultSettings()); + this.Logger.Log("Init of AudioFormatReader succeeded"); + } + catch (e) { + this.Logger.Log("Init of AudioFormatReader failed: " + e); + throw new Error(); + } + this.PacketModCounter = 0; + this.LastCheckTime = 0; + this.FocusChecker = 0; + } + Fallback.prototype.Init = function (webSocket) { + this.MobileUnmute(); + this.WebSocket = webSocket; + this.WebSocket.Send(JSON.stringify({ + "type": "fallback", + "data": this.SelectedFormatName + })); + this.StartFocusChecker(); + }; + Fallback.prototype.MobileUnmute = function () { + var amplification = this.Audio.createGain(); + // Set volume to max + amplification.gain.value = 1.0; + // Connect gain node to context + amplification.connect(this.Audio.destination); + // Create one second buffer with silence + var audioBuffer = this.Audio.createBuffer(2, this.Audio.sampleRate, this.Audio.sampleRate); + // Create new audio source for the buffer + var sourceNode = this.Audio.createBufferSource(); + // Make sure the node deletes itself after playback + sourceNode.onended = function (_ev) { + sourceNode.disconnect(); + amplification.disconnect(); + }; + // Pass audio data to source + sourceNode.buffer = audioBuffer; + // Connect the source to the gain node + sourceNode.connect(amplification); + // Play source + sourceNode.start(); + }; + Object.defineProperty(Fallback.prototype, "Volume", { + get: function () { + return this.Player.Volume / this.Settings.MaxVolume; + }, + set: function (value) { + this.Player.Volume = value * this.Settings.MaxVolume; + }, + enumerable: false, + configurable: true + }); + // Callback functions from format reader + Fallback.prototype.OnReaderError = function () { + this.Logger.Log("Reader error: Decoding failed."); + }; + Fallback.prototype.OnReaderDataReady = function () { + while (this.FormatReader.SamplesAvailable()) { + this.Player.PushBuffer(this.FormatReader.PopSamples()); + } + }; + // Callback function from socket connection + Fallback.prototype.OnSocketError = function (message) { + }; + Fallback.prototype.OnSocketConnect = function () { + }; + Fallback.prototype.OnSocketDisconnect = function () { + }; + Fallback.prototype.OnSocketDataReady = function (data) { + this.PacketModCounter++; + if (this.PacketModCounter > 100) { + if (this.ActivityCallback) + this.ActivityCallback(); + this.PacketModCounter = 0; + } + this.FormatReader.PushData(new Uint8Array(data)); + }; + Fallback.prototype.StartFocusChecker = function () { + if (!this.FocusChecker) { + this.LastCheckTime = Date.now(); + this.FocusChecker = window.setInterval(this.CheckFocus.bind(this), 2000); + } + }; + Fallback.prototype.StopFocusChecker = function () { + if (this.FocusChecker) { + window.clearInterval(this.FocusChecker); + this.FocusChecker = 0; + } + }; + Fallback.prototype.CheckFocus = function () { + var checkTime = Date.now(); + // Check if focus was lost + if (checkTime - this.LastCheckTime > 10000) { + // If so, drop all samples in the buffer + this.Logger.Log("Focus lost, purging format reader."); + this.FormatReader.PurgeData(); + } + this.LastCheckTime = checkTime; + }; + Fallback.prototype.Reset = function () { + this.StopFocusChecker(); + this.FormatReader.Reset(); + this.Player.Reset(); + this.WebSocket = null; + }; + return Fallback; +}()); \ No newline at end of file diff --git a/web/js/3las/fallback/3las.formatreader.js b/web/js/3las/fallback/3las.formatreader.js new file mode 100644 index 0000000..d998d3b --- /dev/null +++ b/web/js/3las/fallback/3las.formatreader.js @@ -0,0 +1,193 @@ +/* + Audio format reader is part of 3LAS (Low Latency Live Audio Streaming) + https://github.com/JoJoBond/3LAS +*/ +var AudioFormatReader = /** @class */ (function () { + function AudioFormatReader(audio, logger, errorCallback, beforeDecodeCheck, dataReadyCallback) { + if (!audio) + throw new Error('AudioFormatReader: audio must be specified'); + // Check callback argument + if (typeof errorCallback !== 'function') + throw new Error('AudioFormatReader: errorCallback must be specified'); + if (typeof beforeDecodeCheck !== 'function') + throw new Error('AudioFormatReader: beforeDecodeCheck must be specified'); + if (typeof dataReadyCallback !== 'function') + throw new Error('AudioFormatReader: dataReadyCallback must be specified'); + this.Audio = audio; + this.Logger = logger; + this.ErrorCallback = errorCallback; + this.BeforeDecodeCheck = beforeDecodeCheck; + this.DataReadyCallback = dataReadyCallback; + this.Id = 0; + this.LastPushedId = -1; + this.Samples = new Array(); + this.BufferStore = {}; + this.DataBuffer = new Uint8Array(0); + } + // Pushes frame data into the buffer + AudioFormatReader.prototype.PushData = function (data) { + // Append data to framedata buffer + this.DataBuffer = this.ConcatUint8Array(this.DataBuffer, data); + // Try to extract frames + this.ExtractAll(); + }; + // Check if samples are available + AudioFormatReader.prototype.SamplesAvailable = function () { + return (this.Samples.length > 0); + }; + // Get a single bunch of sampels from the reader + AudioFormatReader.prototype.PopSamples = function () { + if (this.Samples.length > 0) { + // Get first bunch of samples, remove said bunch from the array and hand it back to callee + return this.Samples.shift(); + } + else + return null; + }; + // Deletes all encoded and decoded data from the reader (does not effect headers, etc.) + AudioFormatReader.prototype.PurgeData = function () { + this.Id = 0; + this.LastPushedId = -1; + this.Samples = new Array(); + this.BufferStore = {}; + this.DataBuffer = new Uint8Array(0); + }; + // Used to force frame extraction externaly + AudioFormatReader.prototype.Poke = function () { + this.ExtractAll(); + }; + // Deletes all data from the reader (does effect headers, etc.) + AudioFormatReader.prototype.Reset = function () { + this.PurgeData(); + }; + // Extracts and converts the raw data + AudioFormatReader.prototype.ExtractAll = function () { + }; + // Checks if a decode makes sense + AudioFormatReader.prototype.OnBeforeDecode = function (id, duration) { + return true; + //TODO Fix this + /* + if(this.BeforeDecodeCheck(duration)) { + return true; + } + else { + this.OnDataReady(id, this.Audio.createBuffer(1, Math.ceil(duration * this.Audio.sampleRate), this.Audio.sampleRate)); + return false; + } + */ + }; + // Stores the converted bnuches of samples in right order + AudioFormatReader.prototype.OnDataReady = function (id, audioBuffer) { + if (this.LastPushedId + 1 == id) { + // Push samples into array + this.Samples.push(audioBuffer); + this.LastPushedId++; + while (this.BufferStore[this.LastPushedId + 1]) { + // Push samples we decoded earlier in correct order + this.Samples.push(this.BufferStore[this.LastPushedId + 1]); + delete this.BufferStore[this.LastPushedId + 1]; + this.LastPushedId++; + } + // Callback to tell that data is ready + this.DataReadyCallback(); + } + else { + // Is out of order, will be pushed later + this.BufferStore[id] = audioBuffer; + } + }; + // Used to concatenate two Uint8Array (b comes BEHIND a) + AudioFormatReader.prototype.ConcatUint8Array = function (a, b) { + var tmp = new Uint8Array(a.length + b.length); + tmp.set(a, 0); + tmp.set(b, a.length); + return tmp; + }; + AudioFormatReader.CanDecodeTypes = function (mimeTypes) { + var audioTag = new Audio(); + var result = false; + for (var i = 0; i < mimeTypes.length; i++) { + var mimeType = mimeTypes[i]; + var answer = audioTag.canPlayType(mimeType); + if (answer != "probably" && answer != "maybe") + continue; + result = true; + break; + } + audioTag = null; + return result; + }; + AudioFormatReader.DefaultSettings = function () { + var settings = {}; + // WAV + settings["wav"] = {}; + // Duration of wave samples to decode together + settings["wav"]["BatchDuration"] = 1 / 10; // 0.1 seconds + /* + if (isAndroid && isNativeChrome) + settings["wav"]["BatchDuration"] = 96 / 375; + else if (isAndroid && isFirefox) + settings["wav"]["BatchDuration"] = 96 / 375; + else + settings["wav"]["BatchDuration"] = 16 / 375; + */ + // Duration of addtional samples to decode to account for edge effects + settings["wav"]["ExtraEdgeDuration"] = 1 / 300; // 0.00333... seconds + /* + if (isAndroid && isNativeChrome) + settings["wav"]["ExtraEdgeDuration"] = 1 / 1000; + else if (isAndroid && isFirefox) + settings["wav"]["ExtraEdgeDuration"] = 1 / 1000; + else + settings["wav"]["ExtraEdgeDuration"] = 1 / 1000; + */ + // MPEG + settings["mpeg"] = {}; + // Adds a minimal ID3v2 tag before decoding frames. + settings["mpeg"]["AddID3Tag"] = false; + // Minimum number of frames to decode together + // Theoretical minimum is 2. + // Recommended value is 3 or higher. + if (isAndroid) + settings["mpeg"]["MinDecodeFrames"] = 17; + else + settings["mpeg"]["MinDecodeFrames"] = 3; + return settings; + }; + AudioFormatReader.Create = function (mime, audio, logger, errorCallback, beforeDecodeCheck, dataReadyCallback, settings) { + if (settings === void 0) { settings = null; } + if (typeof mime !== "string") + throw new Error('CreateAudioFormatReader: Invalid MIME-Type, must be string'); + if (!settings) + settings = this.DefaultSettings(); + var fullMime = mime; + if (mime.indexOf("audio/pcm") == 0) + mime = "audio/pcm"; + // Load format handler according to MIME-Type + switch (mime.replace(/\s/g, "")) { + // MPEG Audio (mp3) + case "audio/mpeg": + case "audio/MPA": + case "audio/mpa-robust": + if (!AudioFormatReader.CanDecodeTypes(new Array("audio/mpeg", "audio/MPA", "audio/mpa-robust"))) + throw new Error('CreateAudioFormatReader: Browser can not decode specified MIME-Type (' + mime + ')'); + return new AudioFormatReader_MPEG(audio, logger, errorCallback, beforeDecodeCheck, dataReadyCallback, settings["mpeg"]["AddID3Tag"], settings["mpeg"]["MinDecodeFrames"]); + break; + // Waveform Audio File Format + case "audio/vnd.wave": + case "audio/wav": + case "audio/wave": + case "audio/x-wav": + if (!AudioFormatReader.CanDecodeTypes(new Array("audio/wav", "audio/wave"))) + throw new Error('CreateAudioFormatReader: Browser can not decode specified MIME-Type (' + mime + ')'); + return new AudioFormatReader_WAV(audio, logger, errorCallback, beforeDecodeCheck, dataReadyCallback, settings["wav"]["BatchDuration"], settings["wav"]["ExtraEdgeDuration"]); + break; + // Unknown codec + default: + throw new Error('CreateAudioFormatReader: Specified MIME-Type (' + mime + ') not supported'); + break; + } + }; + return AudioFormatReader; +}()); \ No newline at end of file diff --git a/web/js/3las/fallback/3las.liveaudioplayer.js b/web/js/3las/fallback/3las.liveaudioplayer.js new file mode 100644 index 0000000..0aef6c6 --- /dev/null +++ b/web/js/3las/fallback/3las.liveaudioplayer.js @@ -0,0 +1,147 @@ +/* + Live audio player is part of 3LAS (Low Latency Live Audio Streaming) + https://github.com/JoJoBond/3LAS +*/ +var LiveAudioPlayer = /** @class */ (function () { + function LiveAudioPlayer(audio, logger, maxVolume, startOffset, variableSpeed) { + if (maxVolume === void 0) { maxVolume = 1.0; } + if (startOffset === void 0) { startOffset = 0.33; } + if (variableSpeed === void 0) { variableSpeed = false; } + this.Audio = audio; + this.Logger = logger; + this.MaxVolume = maxVolume; + this.StartOffset = startOffset; + this.VariableSpeed = variableSpeed; + this.OffsetMin = this.StartOffset - LiveAudioPlayer.OffsetVariance; + this.OffsetMax = this.StartOffset + LiveAudioPlayer.OffsetVariance; + // Set speed to default + this.PlaybackSpeed = 1.0; + // Reset variable for scheduling times + this.NextScheduleTime = 0.0; + // Create gain node for volume control + this.Amplification = this.Audio.createGain(); + // Set volume to max + this.Amplification.gain.value = 1.0; + // Connect gain node to context + this.Amplification.connect(this.Audio.destination); + } + Object.defineProperty(LiveAudioPlayer.prototype, "Volume", { + get: function () { + // Get volume from gain node + return this.Amplification.gain.value; + }, + set: function (value) { + // Clamp value to [1e-20 ; MaxVolume] + if (value > 1.0) + value = this.MaxVolume; + else if (value <= 0.0) + value = 1e-20; + // Cancel any scheduled ramps + this.Amplification.gain.cancelScheduledValues(this.Audio.currentTime); + // Change volume following a ramp (more userfriendly) + this.Amplification.gain.exponentialRampToValueAtTime(value, this.Audio.currentTime + 0.5); + }, + enumerable: false, + configurable: true + }); + // Recieves an audiobuffer and schedules it for seamless playback + LiveAudioPlayer.prototype.PushBuffer = function (buffer) { + // Check if this is the first buffer we received + if (this.NextScheduleTime == 0.0) { + // Start playing [StartOffset] s from now + this.NextScheduleTime = this.Audio.currentTime + this.StartOffset; + } + var duration; + if (this.VariableSpeed) + duration = buffer.duration / this.PlaybackSpeed; // Use regular duration + else + duration = buffer.duration; // Use duration adjusted for playback speed + // Before creating a buffer and scheduling playback, check if playing this buffer makes sense at all + // If a buffer should have been started so far in the past that it would have finished playing by now, we are better of skipping it. + // But we still need to move the time forward to keep future timings right. + if (this.NextScheduleTime + duration > this.Audio.currentTime) { + var skipDurationTime = void 0; + // If the playback start time is in the past but the playback end time is in the future, we need to partially play the buffer. + if (this.Audio.currentTime >= this.NextScheduleTime) { + // Calculate the time we need to skip + skipDurationTime = this.Audio.currentTime - this.NextScheduleTime + 0.05; + } + else { + // No skipping needed + skipDurationTime = 0.0; + } + // Check if we'd skip the whole buffer anyway + if (skipDurationTime < duration) { + // Create new audio source for the buffer + var sourceNode_1 = this.Audio.createBufferSource(); + // Make sure the node deletes itself after playback + sourceNode_1.onended = function (_ev) { + sourceNode_1.disconnect(); + }; + // Prevent looping (the standard says that it should be off by default) + sourceNode_1.loop = false; + // Pass audio data to source + sourceNode_1.buffer = buffer; + //Connect the source to the gain node + sourceNode_1.connect(this.Amplification); + if (this.VariableSpeed) { + var scheduleOffset = this.NextScheduleTime - this.Audio.currentTime; + // Check if we are to far or too close to target schedule time + if (this.NextScheduleTime - this.Audio.currentTime > this.OffsetMax) { + if (this.PlaybackSpeed < 1.0 + LiveAudioPlayer.SpeedCorrectionFactor) { + // We are too slow, speed up playback (somewhat noticeable) + this.Logger.Log("Buffer size too large, speeding up playback."); + this.PlaybackSpeed = 1.0 + LiveAudioPlayer.SpeedCorrectionFactor; + duration = buffer.duration / this.PlaybackSpeed; + } + } + else if (this.NextScheduleTime - this.Audio.currentTime < this.OffsetMin) { + if (this.PlaybackSpeed > 1.0 - LiveAudioPlayer.SpeedCorrectionFactor) { + // We are too fast, slow down playback (somewhat noticeable) + this.Logger.Log("Buffer size too small, slowing down playback."); + this.PlaybackSpeed = 1.0 - LiveAudioPlayer.SpeedCorrectionFactor; + duration = buffer.duration / this.PlaybackSpeed; + } + } + else { + // Check if we are in time + if ((this.PlaybackSpeed > 1.0 && (this.NextScheduleTime - this.Audio.currentTime < this.StartOffset)) || + (this.PlaybackSpeed < 1.0 && (this.NextScheduleTime - this.Audio.currentTime > this.StartOffset))) { + // We are within our min/max offset, set playpacks to default + this.Logger.Log("Buffer size within limits, using normal playback speed."); + this.PlaybackSpeed = 1.0; + duration = buffer.duration; + } + } + // Set playback speed + sourceNode_1.playbackRate.value = this.PlaybackSpeed; + } + // Schedule playback + sourceNode_1.start(this.NextScheduleTime + skipDurationTime, skipDurationTime); + } + else { + this.Logger.Log("Skipped buffer because it became too old."); + } + } + else { + this.Logger.Log("Skipped buffer because it was too old."); + } + // Move time forward + this.NextScheduleTime += duration; + }; + LiveAudioPlayer.prototype.Reset = function () { + this.NextScheduleTime = 0.0; + }; + LiveAudioPlayer.prototype.CheckBeforeDecode = function (playbackLength) { + if (this.NextScheduleTime == 0) + return true; + return this.NextScheduleTime + playbackLength > this.Audio.currentTime; + }; + // Crystal oscillator have a variance of about +/- 20ppm + // So worst case would be a difference of 40ppm between two oscillators. + LiveAudioPlayer.SpeedCorrectionFactor = 40 / 1.0e6; + // Hystersis value for speed up/down trigger + LiveAudioPlayer.OffsetVariance = 0.2; + return LiveAudioPlayer; +}()); +//# sourceMappingURL=3las.liveaudioplayer.js.map \ No newline at end of file diff --git a/web/js/3las/fallback/formats/3las.formatreader.mpeg.js b/web/js/3las/fallback/formats/3las.formatreader.mpeg.js new file mode 100644 index 0000000..85a1a70 --- /dev/null +++ b/web/js/3las/fallback/formats/3las.formatreader.mpeg.js @@ -0,0 +1,284 @@ +/* + MPEG audio format reader is part of 3LAS (Low Latency Live Audio Streaming) + https://github.com/JoJoBond/3LAS +*/ +var __extends = (this && this.__extends) || (function () { + var extendStatics = function (d, b) { + extendStatics = Object.setPrototypeOf || + ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) || + function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; }; + return extendStatics(d, b); + }; + return function (d, b) { + if (typeof b !== "function" && b !== null) + throw new TypeError("Class extends value " + String(b) + " is not a constructor or null"); + extendStatics(d, b); + function __() { this.constructor = d; } + d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); + }; +})(); +var MPEGFrameInfo = /** @class */ (function () { + function MPEGFrameInfo(data, sampleCount, sampleRate) { + this.Data = data; + this.SampleCount = sampleCount; + this.SampleRate = sampleRate; + } + return MPEGFrameInfo; +}()); +var AudioFormatReader_MPEG = /** @class */ (function (_super) { + __extends(AudioFormatReader_MPEG, _super); + function AudioFormatReader_MPEG(audio, logger, errorCallback, beforeDecodeCheck, dataReadyCallback, addId3Tag, minDecodeFrames) { + var _this = _super.call(this, audio, logger, errorCallback, beforeDecodeCheck, dataReadyCallback) || this; + _this._OnDecodeSuccess = _this.OnDecodeSuccess.bind(_this); + _this._OnDecodeError = _this.OnDecodeError.bind(_this); + _this.AddId3Tag = addId3Tag; + _this.MinDecodeFrames = minDecodeFrames; + _this.Frames = new Array(); + _this.FrameStartIdx = -1; + _this.FrameEndIdx = -1; + _this.FrameSamples = 0; + _this.FrameSampleRate = 0; + _this.TimeBudget = 0; + return _this; + } + // Deletes all frames from the databuffer and framearray and all samples from the samplearray + AudioFormatReader_MPEG.prototype.PurgeData = function () { + _super.prototype.PurgeData.call(this); + this.Frames = new Array(); + this.FrameStartIdx = -1; + this.FrameEndIdx = -1; + this.FrameSamples = 0; + this.FrameSampleRate = 0; + this.TimeBudget = 0; + }; + // Extracts all currently possible frames + AudioFormatReader_MPEG.prototype.ExtractAll = function () { + // Look for frames + this.FindFrame(); + // Repeat as long as we can extract frames + while (this.CanExtractFrame()) { + // Extract frame and push into array + this.Frames.push(this.ExtractFrame()); + // Look for frames + this.FindFrame(); + } + // Check if we have enough frames to decode + if (this.Frames.length >= this.MinDecodeFrames) { + // Note: + // ===== + // mp3 frames have an overlap of [granule size] so we can't use the first or last [granule size] samples. + // [granule size] is equal to half of a [frame size] in samples (using the mp3's sample rate). + // Sum up the playback time of each decoded frame and data buffer lengths + // Note: Since mp3-Frames overlap by half of their sample-length we expect the + // first and last frame to be only half as long. Some decoders will still output + // the full frame length by adding zeros. + var bufferLength = 0; + var expectedTotalPlayTime_1 = 0; + expectedTotalPlayTime_1 += this.Frames[0].SampleCount / this.Frames[0].SampleRate / 2.0; // Only half of data is usable due to overlap + bufferLength += this.Frames[0].Data.length; + for (var i = 1; i < this.Frames.length - 1; i++) { + expectedTotalPlayTime_1 += this.Frames[i].SampleCount / this.Frames[i].SampleRate; + bufferLength += this.Frames[i].Data.length; + } + expectedTotalPlayTime_1 += this.Frames[this.Frames.length - 1].SampleCount / this.Frames[this.Frames.length - 1].SampleRate / 2.0; // Only half of data is usable due to overlap + bufferLength += this.Frames[this.Frames.length - 1].Data.length; + // If needed, add some space for the ID3v2 tag + if (this.AddId3Tag) { + bufferLength += AudioFormatReader_MPEG.Id3v2Tag.length; + } + // Create a buffer long enough to hold everything + var decodeBuffer = new Uint8Array(bufferLength); + var offset = 0; + // If needed, add ID3v2 tag to beginning of buffer + if (this.AddId3Tag) { + decodeBuffer.set(AudioFormatReader_MPEG.Id3v2Tag, offset); + offset += AudioFormatReader_MPEG.Id3v2Tag.length; + } + // Add the frames to the window + for (var i = 0; i < this.Frames.length; i++) { + decodeBuffer.set(this.Frames[i].Data, offset); + offset += this.Frames[i].Data.length; + } + // Remove the used frames from the array + this.Frames.splice(0, this.Frames.length - 1); + // Increment Id + var id_1 = this.Id++; + // Check if decoded frames might be too far back in the past + if (!this.OnBeforeDecode(id_1, expectedTotalPlayTime_1)) + return; + // Push window to the decoder + this.Audio.decodeAudioData(decodeBuffer.buffer, (function (decodedData) { + var _id = id_1; + var _expectedTotalPlayTime = expectedTotalPlayTime_1; + this._OnDecodeSuccess(decodedData, _id, _expectedTotalPlayTime); + }).bind(this), this._OnDecodeError.bind(this)); + } + }; + // Finds frame boundries within the data buffer + AudioFormatReader_MPEG.prototype.FindFrame = function () { + // Find frame start + if (this.FrameStartIdx < 0) { + var i = 0; + // Make sure we don't exceed array bounds + while ((i + 1) < this.DataBuffer.length) { + // Look for MPEG sync word + if (this.DataBuffer[i] == 0xFF && (this.DataBuffer[i + 1] & 0xE0) == 0xE0) { + // Sync found, set frame start + this.FrameStartIdx = i; + break; + } + i++; + } + } + // Find frame end + if (this.FrameStartIdx >= 0 && this.FrameEndIdx < 0) { + // Check if we have enough data to process the header + if ((this.FrameStartIdx + 2) < this.DataBuffer.length) { + // Get header data + // Version index + var ver = (this.DataBuffer[this.FrameStartIdx + 1] & 0x18) >>> 3; + // Layer index + var lyr = (this.DataBuffer[this.FrameStartIdx + 1] & 0x06) >>> 1; + // Padding? 0/1 + var pad = (this.DataBuffer[this.FrameStartIdx + 2] & 0x02) >>> 1; + // Bitrate index + var brx = (this.DataBuffer[this.FrameStartIdx + 2] & 0xF0) >>> 4; + // SampRate index + var srx = (this.DataBuffer[this.FrameStartIdx + 2] & 0x0C) >>> 2; + // Resolve flags to real values + var bitrate = AudioFormatReader_MPEG.MPEG_bitrates[ver][lyr][brx] * 1000; + var samprate = AudioFormatReader_MPEG.MPEG_srates[ver][srx]; + var samples = AudioFormatReader_MPEG.MPEG_frame_samples[ver][lyr]; + var slot_size = AudioFormatReader_MPEG.MPEG_slot_size[lyr]; + // In-between calculations + var bps = samples / 8.0; + var fsize = ((bps * bitrate) / samprate) + ((pad == 1) ? slot_size : 0); + // Truncate to integer + var frameSize = Math.floor(fsize); + // Store number of samples and samplerate for frame + this.FrameSamples = samples; + this.FrameSampleRate = samprate; + // Set end frame boundry + this.FrameEndIdx = this.FrameStartIdx + frameSize; + } + } + }; + // Checks if there is a frame ready to be extracted + AudioFormatReader_MPEG.prototype.CanExtractFrame = function () { + if (this.FrameStartIdx < 0 || this.FrameEndIdx < 0) + return false; + else if (this.FrameEndIdx <= this.DataBuffer.length) + return true; + else + return false; + }; + // Extract a single frame from the buffer + AudioFormatReader_MPEG.prototype.ExtractFrame = function () { + // Extract frame data from buffer + var frameArray = this.DataBuffer.buffer.slice(this.FrameStartIdx, this.FrameEndIdx); + // Remove frame from buffer + if ((this.FrameEndIdx + 1) < this.DataBuffer.length) + this.DataBuffer = new Uint8Array(this.DataBuffer.buffer.slice(this.FrameEndIdx)); + else + this.DataBuffer = new Uint8Array(0); + // Reset Start/End indices + this.FrameStartIdx = 0; + this.FrameEndIdx = -1; + return new MPEGFrameInfo(new Uint8Array(frameArray), this.FrameSamples, this.FrameSampleRate); + }; + // Is called if the decoding of the window succeeded + AudioFormatReader_MPEG.prototype.OnDecodeSuccess = function (decodedData, id, expectedTotalPlayTime) { + var extractSampleCount; + var extractSampleOffset; + // Check if we got the expected number of samples + if (expectedTotalPlayTime > decodedData.duration) { + // We got less samples than expect, we suspect that they were truncated equally at start and end. + // This can happen in case of sample rate conversions. + extractSampleCount = decodedData.length; + extractSampleOffset = 0; + this.TimeBudget += (expectedTotalPlayTime - decodedData.duration); + } + else if (expectedTotalPlayTime < decodedData.duration) { + // We got more samples than expect, we suspect that zeros were added equally at start and end. + // This can happen in case of sample rate conversions or edge frame handling. + extractSampleCount = Math.ceil(expectedTotalPlayTime * decodedData.sampleRate); + var budgetSamples = this.TimeBudget * decodedData.sampleRate; + if (budgetSamples > 1.0) { + if (budgetSamples > decodedData.length - extractSampleCount) { + budgetSamples = decodedData.length - extractSampleCount; + } + extractSampleCount += budgetSamples; + this.TimeBudget -= (budgetSamples / decodedData.sampleRate); + } + extractSampleOffset = Math.floor((decodedData.length - extractSampleCount) / 2); + } + else { + // We got the expected number of samples, no adaption needed + extractSampleCount = decodedData.length; + extractSampleOffset = 0; + } + // Create a buffer that can hold the frame to extract + var audioBuffer = this.Audio.createBuffer(decodedData.numberOfChannels, extractSampleCount, decodedData.sampleRate); + // Fill buffer with the last part of the decoded frame leave out last granule + for (var i = 0; i < decodedData.numberOfChannels; i++) + audioBuffer.getChannelData(i).set(decodedData.getChannelData(i).subarray(extractSampleOffset, extractSampleOffset + extractSampleCount)); + this.OnDataReady(id, audioBuffer); + }; + // Is called in case the decoding of the window fails + AudioFormatReader_MPEG.prototype.OnDecodeError = function (_error) { + this.ErrorCallback(); + }; + // MPEG versions - use [version] + AudioFormatReader_MPEG.MPEG_versions = new Array(25, 0, 2, 1); + // Layers - use [layer] + AudioFormatReader_MPEG.MPEG_layers = new Array(0, 3, 2, 1); + // Bitrates - use [version][layer][bitrate] + AudioFormatReader_MPEG.MPEG_bitrates = new Array(new Array(// Version 2.5 + new Array(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), // Reserved + new Array(0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0), // Layer 3 + new Array(0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0), // Layer 2 + new Array(0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, 0) // Layer 1 + ), new Array(// Reserved + new Array(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), // Invalid + new Array(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), // Invalid + new Array(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), // Invalid + new Array(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) // Invalid + ), new Array(// Version 2 + new Array(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), // Reserved + new Array(0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0), // Layer 3 + new Array(0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0), // Layer 2 + new Array(0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, 0) // Layer 1 + ), new Array(// Version 1 + new Array(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), // Reserved + new Array(0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0), // Layer 3 + new Array(0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 0), // Layer 2 + new Array(0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, 0) // Layer 1 + )); + // Sample rates - use [version][srate] + AudioFormatReader_MPEG.MPEG_srates = new Array(new Array(11025, 12000, 8000, 0), // MPEG 2.5 + new Array(0, 0, 0, 0), // Reserved + new Array(22050, 24000, 16000, 0), // MPEG 2 + new Array(44100, 48000, 32000, 0) // MPEG 1 + ); + // Samples per frame - use [version][layer] + AudioFormatReader_MPEG.MPEG_frame_samples = new Array( + // Rsvd 3 2 1 < Layer v Version + new Array(0, 576, 1152, 384), // 2.5 + new Array(0, 0, 0, 0), // Reserved + new Array(0, 576, 1152, 384), // 2 + new Array(0, 1152, 1152, 384) // 1 + ); + AudioFormatReader_MPEG.Id3v2Tag = new Uint8Array(new Array(0x49, 0x44, 0x33, // File identifier: "ID3" + 0x03, 0x00, // Version 2.3 + 0x00, // Flags: no unsynchronisation, no extended header, no experimental indicator + 0x00, 0x00, 0x00, 0x0D, // Size of the (tag-)frames, extended header and padding + 0x54, 0x49, 0x54, 0x32, // Title frame: "TIT2" + 0x00, 0x00, 0x00, 0x02, // Size of the frame data + 0x00, 0x00, // Frame Flags + 0x00, 0x20, 0x00 // Frame data (space character) and padding + )); + // Slot size (MPEG unit of measurement) - use [layer] + AudioFormatReader_MPEG.MPEG_slot_size = new Array(0, 1, 1, 4); // Rsvd, 3, 2, 1 + return AudioFormatReader_MPEG; +}(AudioFormatReader)); +//# sourceMappingURL=3las.formatreader.mpeg.js.map \ No newline at end of file diff --git a/web/js/3las/fallback/formats/3las.formatreader.wav.js b/web/js/3las/fallback/formats/3las.formatreader.wav.js new file mode 100644 index 0000000..33ca22a --- /dev/null +++ b/web/js/3las/fallback/formats/3las.formatreader.wav.js @@ -0,0 +1,223 @@ +/* + WAV audio format reader is part of 3LAS (Low Latency Live Audio Streaming) + https://github.com/JoJoBond/3LAS +*/ +var __extends = (this && this.__extends) || (function () { + var extendStatics = function (d, b) { + extendStatics = Object.setPrototypeOf || + ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) || + function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; }; + return extendStatics(d, b); + }; + return function (d, b) { + if (typeof b !== "function" && b !== null) + throw new TypeError("Class extends value " + String(b) + " is not a constructor or null"); + extendStatics(d, b); + function __() { this.constructor = d; } + d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); + }; +})(); +var AudioFormatReader_WAV = /** @class */ (function (_super) { + __extends(AudioFormatReader_WAV, _super); + function AudioFormatReader_WAV(audio, logger, errorCallback, beforeDecodeCheck, dataReadyCallback, batchDuration, extraEdgeDuration) { + var _this = _super.call(this, audio, logger, errorCallback, beforeDecodeCheck, dataReadyCallback) || this; + _this._OnDecodeSuccess = _this.OnDecodeSuccess.bind(_this); + _this._OnDecodeError = _this.OnDecodeError.bind(_this); + _this.BatchDuration = batchDuration; + _this.ExtraEdgeDuration = extraEdgeDuration; + _this.GotHeader = false; + _this.RiffHeader = null; + _this.WaveSampleRate = 0; + _this.WaveBitsPerSample = 0; + _this.WaveBytesPerSample = 0; + _this.WaveBlockAlign = 0; + _this.WaveChannels = 0; + _this.BatchSamples = 0; + _this.BatchBytes = 0; + _this.ExtraEdgeSamples = 0; + _this.TotalBatchSampleSize = 0; + _this.TotalBatchByteSize = 0; + _this.SampleBudget = 0; + return _this; + } + // Deletes all samples from the databuffer and the samplearray + AudioFormatReader_WAV.prototype.PurgeData = function () { + _super.prototype.PurgeData.call(this); + this.SampleBudget = 0; + }; + // Deletes all data from the reader (deos effect headers, etc.) + AudioFormatReader_WAV.prototype.Reset = function () { + _super.prototype.Reset.call(this); + this.GotHeader = false; + this.RiffHeader = null; + this.WaveSampleRate = 0; + this.WaveBitsPerSample = 0; + this.WaveBytesPerSample = 0; + this.WaveBlockAlign = 0; + this.WaveChannels = 0; + this.BatchSamples = 0; + this.BatchBytes = 0; + this.ExtraEdgeSamples = 0; + this.TotalBatchSampleSize = 0; + this.TotalBatchByteSize = 0; + this.SampleBudget = 0; + }; + AudioFormatReader_WAV.prototype.ExtractAll = function () { + if (!this.GotHeader) + this.FindAndExtractHeader(); + else { + var _loop_1 = function () { + // Extract samples + var tmpSamples = this_1.ExtractIntSamples(); + // Increment Id + var id = this_1.Id++; + if (!this_1.OnBeforeDecode(id, this_1.BatchDuration)) + return "continue"; + // Note: + // ===== + // When audio data is resampled we get edge-effects at beginnging and end. + // We should be able to compensate for that by keeping the last sample of the + // previous batch and adding it to the beginning of the current one, but then + // cutting it out AFTER the resampling (since the same effects apply to it) + // The effects at the end can be compensated by cutting the resampled samples shorter + // This is not trivial for non-natural ratios (e.g. 16kHz -> 44.1kHz). Because we would have + // to cut out a non-natural number of samples at beginning and end. + // TODO: All of the above... + // Create a buffer long enough to hold everything + var samplesBuffer = new Uint8Array(this_1.RiffHeader.length + tmpSamples.length); + var offset = 0; + // Add header + samplesBuffer.set(this_1.RiffHeader, offset); + offset += this_1.RiffHeader.length; + // Add samples + samplesBuffer.set(tmpSamples, offset); + // Push pages to the decoder + this_1.Audio.decodeAudioData(samplesBuffer.buffer, (function (decodedData) { + var _id = id; + this._OnDecodeSuccess(decodedData, _id); + }).bind(this_1), this_1._OnDecodeError); + }; + var this_1 = this; + while (this.CanExtractSamples()) { + _loop_1(); + } + } + }; + // Finds riff header within the data buffer and extracts it + AudioFormatReader_WAV.prototype.FindAndExtractHeader = function () { + var curpos = 0; + // Make sure a whole header can fit + if (!((curpos + 4) < this.DataBuffer.length)) + return; + // Check chunkID, should be "RIFF" + if (!(this.DataBuffer[curpos] == 0x52 && this.DataBuffer[curpos + 1] == 0x49 && this.DataBuffer[curpos + 2] == 0x46 && this.DataBuffer[curpos + 3] == 0x46)) + return; + curpos += 8; + if (!((curpos + 4) < this.DataBuffer.length)) + return; + // Check riffType, should be "WAVE" + if (!(this.DataBuffer[curpos] == 0x57 && this.DataBuffer[curpos + 1] == 0x41 && this.DataBuffer[curpos + 2] == 0x56 && this.DataBuffer[curpos + 3] == 0x45)) + return; + curpos += 4; + if (!((curpos + 4) < this.DataBuffer.length)) + return; + // Check for format subchunk, should be "fmt " + if (!(this.DataBuffer[curpos] == 0x66 && this.DataBuffer[curpos + 1] == 0x6d && this.DataBuffer[curpos + 2] == 0x74 && this.DataBuffer[curpos + 3] == 0x20)) + return; + curpos += 4; + if (!((curpos + 4) < this.DataBuffer.length)) + return; + var subChunkSize = this.DataBuffer[curpos] | this.DataBuffer[curpos + 1] << 8 | this.DataBuffer[curpos + 2] << 16 | this.DataBuffer[curpos + 3] << 24; + if (!((curpos + 4 + subChunkSize) < this.DataBuffer.length)) + return; + curpos += 6; + this.WaveChannels = this.DataBuffer[curpos] | this.DataBuffer[curpos + 1] << 8; + curpos += 2; + this.WaveSampleRate = this.DataBuffer[curpos] | this.DataBuffer[curpos + 1] << 8 | this.DataBuffer[curpos + 2] << 16 | this.DataBuffer[curpos + 3] << 24; + curpos += 8; + this.WaveBlockAlign = this.DataBuffer[curpos] | this.DataBuffer[curpos + 1] << 8; + curpos += 2; + this.WaveBitsPerSample = this.DataBuffer[curpos] | this.DataBuffer[curpos + 1] << 8; + this.WaveBytesPerSample = this.WaveBitsPerSample / 8; + curpos += subChunkSize - 14; + while (true) { + if ((curpos + 8) < this.DataBuffer.length) { + subChunkSize = this.DataBuffer[curpos + 4] | this.DataBuffer[curpos + 5] << 8 | this.DataBuffer[curpos + 6] << 16 | this.DataBuffer[curpos + 7] << 24; + // Check for data subchunk, should be "data" + if (this.DataBuffer[curpos] == 0x64 && this.DataBuffer[curpos + 1] == 0x61 && this.DataBuffer[curpos + 2] == 0x74 && this.DataBuffer[curpos + 3] == 0x61) // Data chunk found + break; + else + curpos += 8 + subChunkSize; + } + else + return; + } + curpos += 8; + this.RiffHeader = new Uint8Array(this.DataBuffer.buffer.slice(0, curpos)); + this.BatchSamples = Math.ceil(this.BatchDuration * this.WaveSampleRate); + this.ExtraEdgeSamples = Math.ceil(this.ExtraEdgeDuration * this.WaveSampleRate); + this.BatchBytes = this.BatchSamples * this.WaveBlockAlign; + this.TotalBatchSampleSize = (this.BatchSamples + this.ExtraEdgeSamples); + this.TotalBatchByteSize = this.TotalBatchSampleSize * this.WaveBlockAlign; + var chunkSize = this.RiffHeader.length + this.TotalBatchByteSize - 8; + // Fix header chunksizes + this.RiffHeader[4] = chunkSize & 0xFF; + this.RiffHeader[5] = (chunkSize & 0xFF00) >>> 8; + this.RiffHeader[6] = (chunkSize & 0xFF0000) >>> 16; + this.RiffHeader[7] = (chunkSize & 0xFF000000) >>> 24; + this.RiffHeader[this.RiffHeader.length - 4] = (this.TotalBatchByteSize & 0xFF); + this.RiffHeader[this.RiffHeader.length - 3] = (this.TotalBatchByteSize & 0xFF00) >>> 8; + this.RiffHeader[this.RiffHeader.length - 2] = (this.TotalBatchByteSize & 0xFF0000) >>> 16; + this.RiffHeader[this.RiffHeader.length - 1] = (this.TotalBatchByteSize & 0xFF000000) >>> 24; + this.GotHeader = true; + }; + // Checks if there is a samples ready to be extracted + AudioFormatReader_WAV.prototype.CanExtractSamples = function () { + if (this.DataBuffer.length >= this.TotalBatchByteSize) + return true; + else + return false; + }; + // Extract a single batch of samples from the buffer + AudioFormatReader_WAV.prototype.ExtractIntSamples = function () { + // Extract sample data from buffer + var intSampleArray = new Uint8Array(this.DataBuffer.buffer.slice(0, this.TotalBatchByteSize)); + // Remove samples from buffer + this.DataBuffer = new Uint8Array(this.DataBuffer.buffer.slice(this.BatchBytes)); + return intSampleArray; + }; + // Is called if the decoding of the samples succeeded + AudioFormatReader_WAV.prototype.OnDecodeSuccess = function (decodedData, id) { + // Calculate the length of the parts + var pickSize = this.BatchDuration * decodedData.sampleRate; + this.SampleBudget += (pickSize - Math.ceil(pickSize)); + pickSize = Math.ceil(pickSize); + var pickOffset = (decodedData.length - pickSize) / 2.0; + if (pickOffset < 0) + pickOffset = 0; // This should never happen! + else + pickOffset = Math.floor(pickOffset); + if (this.SampleBudget < -1.0) { + var correction = -1.0 * Math.floor(Math.abs(this.SampleBudget)); + this.SampleBudget -= correction; + pickSize += correction; + } + else if (this.SampleBudget > 1.0) { + var correction = Math.floor(this.SampleBudget); + this.SampleBudget -= correction; + pickSize += correction; + } + // Create a buffer that can hold a single part + var audioBuffer = this.Audio.createBuffer(decodedData.numberOfChannels, pickSize, decodedData.sampleRate); + // Fill buffer with the last part of the decoded frame + for (var i = 0; i < decodedData.numberOfChannels; i++) + audioBuffer.getChannelData(i).set(decodedData.getChannelData(i).slice(pickOffset, -pickOffset)); + this.OnDataReady(id, audioBuffer); + }; + // Is called in case the decoding of the window fails + AudioFormatReader_WAV.prototype.OnDecodeError = function (_error) { + this.ErrorCallback(); + }; + return AudioFormatReader_WAV; +}(AudioFormatReader)); +//# sourceMappingURL=3las.formatreader.wav.js.map \ No newline at end of file diff --git a/web/js/3las/main.js b/web/js/3las/main.js new file mode 100644 index 0000000..b4d9170 --- /dev/null +++ b/web/js/3las/main.js @@ -0,0 +1,49 @@ +var Stream; +var DefaultVolume = 0.5; +function Init(_ev) { + // Load default settings + var settings = new _3LAS_Settings(); + if (typeof RtcConfig == 'undefined') + RtcConfig = {}; + settings.WebRTC.RtcConfig = RtcConfig; + if (typeof SocketPort != 'undefined') + settings.SocketPort = SocketPort; + if (typeof SocketPath != 'undefined') + settings.SocketPath = SocketPath; + if (typeof AudioTagId == 'undefined') + settings.WebRTC.AudioTag = null; + else + settings.WebRTC.AudioTag = document.getElementById(AudioTagId); + try { + Stream = new _3LAS(null, settings); + } + catch (_ex) { + console.log(_ex); + return; + } + Stream.ConnectivityCallback = OnConnectivityCallback; + document.getElementById("playbutton").onclick = OnPlayButtonClick; + $("#volumeSlider").on("change", updateVolume); +} + +function OnConnectivityCallback(isConnected) { + if (isConnected) { + Stream.Volume = 1.0; + } +} + +function OnPlayButtonClick(_ev) { + try { + Stream.Start(); + $('#playbutton').prop('disabled', true); + $('#playbutton').find('.fa-solid').removeClass('fa-play').addClass('fa-pause'); + } + catch (_ex) { + } +} + +function updateVolume() { + Stream.Volume = $(this).val(); +} + +var lastTapTime = -1; \ No newline at end of file diff --git a/web/js/3las/util/3las.helpers.js b/web/js/3las/util/3las.helpers.js new file mode 100644 index 0000000..052f9a2 --- /dev/null +++ b/web/js/3las/util/3las.helpers.js @@ -0,0 +1,130 @@ +/* + Helpers is part of 3LAS (Low Latency Live Audio Streaming) + https://github.com/JoJoBond/3LAS +*/ +var isAndroid; +var isIOS; +var isIPadOS; +var isWindows; +var isLinux; +var isBSD; +var isMacOSX; +var isInternetExplorer; +var isEdge; +; +var isSafari; +; +var isOpera; +; +var isChrome; +; +var isFirefox; +; +var webkitVer; +var isNativeChrome; +; +var BrowserName; +var OSName; +{ + var ua = navigator.userAgent.toLowerCase(); + isAndroid = (ua.match('android') ? true : false); + isIOS = (ua.match(/(iphone|ipod)/g) ? true : false); + isIPadOS = ((ua.match('ipad') || (navigator.platform == 'MacIntel' && navigator.maxTouchPoints > 1)) ? true : false); + isWindows = (ua.match('windows') ? true : false); + isLinux = (ua.match('android') ? false : (ua.match('linux') ? true : false)); + isBSD = (ua.match('bsd') ? true : false); + isMacOSX = !isIOS && !isIPadOS && (ua.match('mac osx') ? true : false); + isInternetExplorer = (ua.match('msie') ? true : false); + isEdge = (ua.match('edg') ? true : false); + isSafari = (ua.match(/(chromium|chrome|crios)/g) ? false : (ua.match('safari') ? true : false)); + isOpera = (ua.match('opera') ? true : false); + isChrome = !isSafari && (ua.match(/(chromium|chrome|crios)/g) ? true : false); + isFirefox = (ua.match('like gecko') ? false : (ua.match(/(gecko|fennec|firefox)/g) ? true : false)); + webkitVer = parseInt((/WebKit\/([0-9]+)/.exec(navigator.appVersion) || ["", "0"])[1], 10) || void 0; // also match AppleWebKit + isNativeChrome = isAndroid && webkitVer <= 537 && navigator.vendor.toLowerCase().indexOf('google') == 0; + BrowserName = "Unknown"; + if (isInternetExplorer) + BrowserName = "IE"; + else if (isEdge) + BrowserName = "Edge"; + else if (isSafari) + BrowserName = "Safari"; + else if (isOpera) + BrowserName = "Opera"; + else if (isChrome) + BrowserName = "Chrome"; + else if (isFirefox) + BrowserName = "Firefox"; + else if (isNativeChrome) + BrowserName = "NativeChrome"; + else + BrowserName = "Unknown"; + OSName = "Unknown"; + if (isAndroid) + OSName = "Android"; + else if (isIOS) + OSName = "iOS"; + else if (isIPadOS) + OSName = "iPadOS"; + else if (isWindows) + OSName = "Windows"; + else if (isLinux) + OSName = "Linux"; + else if (isBSD) + OSName = "BSD"; + else if (isMacOSX) + OSName = "MacOSX"; + else + OSName = "Unknown"; +} +; +var WakeLock = /** @class */ (function () { + function WakeLock(logger) { + this.Logger = logger; + this.Logger.Log("Preparing WakeLock"); + if (typeof navigator.wakeLock == "undefined") { + this.Logger.Log("Using video loop method."); + var video = document.createElement('video'); + video.setAttribute('loop', ''); + video.setAttribute('style', 'position: fixed; opacity: 0.1; pointer-events: none;'); + WakeLock.AddSourceToVideo(video, 'webm', 'data:video/webm;base64,' + WakeLock.VideoWebm); + WakeLock.AddSourceToVideo(video, 'mp4', 'data:video/mp4;base64,' + WakeLock.VideoMp4); + document.body.appendChild(video); + this.LockElement = video; + } + else { + this.Logger.Log("Using WakeLock API."); + this.LockElement = null; + } + } + WakeLock.prototype.Begin = function () { + var _this = this; + if (this.LockElement == null) { + try { + navigator.wakeLock.request("screen").then(function (obj) { + _this.Logger.Log("WakeLock request successful. Lock acquired."); + _this.LockElement = obj; + }, function () { + _this.Logger.Log("WakeLock request failed."); + }); + } + catch (err) { + this.Logger.Log("WakeLock request failed."); + } + } + else { + this.Logger.Log("WakeLock video loop started."); + this.LockElement.play(); + } + }; + WakeLock.AddSourceToVideo = function (element, type, dataURI) { + var source = document.createElement('source'); + source.src = dataURI; + source.type = 'video/' + type; + element.appendChild(source); + }; + WakeLock.VideoWebm = 'GkXfo0AgQoaBAUL3gQFC8oEEQvOBCEKCQAR3ZWJtQoeBAkKFgQIYU4BnQI0VSalmQCgq17FAAw9CQE2AQAZ3aGFtbXlXQUAGd2hhbW15RIlACECPQAAAAAAAFlSua0AxrkAu14EBY8WBAZyBACK1nEADdW5khkAFVl9WUDglhohAA1ZQOIOBAeBABrCBCLqBCB9DtnVAIueBAKNAHIEAAIAwAQCdASoIAAgAAUAmJaQAA3AA/vz0AAA='; + WakeLock.VideoMp4 = 'AAAAHGZ0eXBpc29tAAACAGlzb21pc28ybXA0MQAAAAhmcmVlAAAAG21kYXQAAAGzABAHAAABthADAowdbb9/AAAC6W1vb3YAAABsbXZoZAAAAAB8JbCAfCWwgAAAA+gAAAAAAAEAAAEAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAIVdHJhawAAAFx0a2hkAAAAD3wlsIB8JbCAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAQAAAAAAIAAAACAAAAAABsW1kaWEAAAAgbWRoZAAAAAB8JbCAfCWwgAAAA+gAAAAAVcQAAAAAAC1oZGxyAAAAAAAAAAB2aWRlAAAAAAAAAAAAAAAAVmlkZW9IYW5kbGVyAAAAAVxtaW5mAAAAFHZtaGQAAAABAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAAEcc3RibAAAALhzdHNkAAAAAAAAAAEAAACobXA0dgAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAIAAgASAAAAEgAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABj//wAAAFJlc2RzAAAAAANEAAEABDwgEQAAAAADDUAAAAAABS0AAAGwAQAAAbWJEwAAAQAAAAEgAMSNiB9FAEQBFGMAAAGyTGF2YzUyLjg3LjQGAQIAAAAYc3R0cwAAAAAAAAABAAAAAQAAAAAAAAAcc3RzYwAAAAAAAAABAAAAAQAAAAEAAAABAAAAFHN0c3oAAAAAAAAAEwAAAAEAAAAUc3RjbwAAAAAAAAABAAAALAAAAGB1ZHRhAAAAWG1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAAK2lsc3QAAAAjqXRvbwAAABtkYXRhAAAAAQAAAABMYXZmNTIuNzguMw=='; + return WakeLock; +}()); +//# sourceMappingURL=3las.helpers.js.map \ No newline at end of file diff --git a/web/js/3las/util/3las.logging.js b/web/js/3las/util/3las.logging.js new file mode 100644 index 0000000..12cdb1b --- /dev/null +++ b/web/js/3las/util/3las.logging.js @@ -0,0 +1,26 @@ +/* + Logging is part of 3LAS (Low Latency Live Audio Streaming) + https://github.com/JoJoBond/3LAS +*/ +var Logging = /** @class */ (function () { + function Logging(parentElement, childElementType) { + this.ParentElement = parentElement; + this.ChildElementType = childElementType; + } + Logging.prototype.Log = function (message) { + var dateTime = new Date(); + var lineText = "[" + (dateTime.getHours() > 9 ? dateTime.getHours() : "0" + dateTime.getHours()) + ":" + + (dateTime.getMinutes() > 9 ? dateTime.getMinutes() : "0" + dateTime.getMinutes()) + ":" + + (dateTime.getSeconds() > 9 ? dateTime.getSeconds() : "0" + dateTime.getSeconds()) + + "] " + message; + if (this.ParentElement && this.ChildElementType) { + var line = document.createElement(this.ChildElementType); + line.innerText = lineText; + this.ParentElement.appendChild(line); + } + else { + console.log(lineText); + } + }; + return Logging; +}()); \ No newline at end of file diff --git a/web/js/3las/util/3las.websocketclient.js b/web/js/3las/util/3las.websocketclient.js new file mode 100644 index 0000000..e290f43 --- /dev/null +++ b/web/js/3las/util/3las.websocketclient.js @@ -0,0 +1,78 @@ +/* + WebSocket client is part of 3LAS (Low Latency Live Audio Streaming) + https://github.com/JoJoBond/3LAS +*/ +var WebSocketClient = /** @class */ (function () { + function WebSocketClient(logger, uri, errorCallback, connectCallback, dataReadyCallback, disconnectCallback) { + this.Logger = logger; + this.Uri = uri; + // Check callback argument + if (typeof errorCallback !== 'function') + throw new Error('WebSocketClient: ErrorCallback must be specified'); + if (typeof connectCallback !== 'function') + throw new Error('WebSocketClient: ConnectCallback must be specified'); + if (typeof dataReadyCallback !== 'function') + throw new Error('WebSocketClient: DataReadyCallback must be specified'); + if (typeof disconnectCallback !== 'function') + throw new Error('WebSocketClient: DisconnectCallback must be specified'); + this.ErrorCallback = errorCallback; + this.ConnectCallback = connectCallback; + this.DataReadyCallback = dataReadyCallback; + this.DisconnectCallback = disconnectCallback; + // Client is not yet connected + this.IsConnected = false; + // Create socket, connect to URI + if (typeof WebSocket !== "undefined") + this.Socket = new WebSocket(this.Uri); + else if (typeof webkitWebSocket !== "undefined") + this.Socket = new webkitWebSocket(this.Uri); + else if (typeof mozWebSocket !== "undefined") + this.Socket = new mozWebSocket(this.Uri); + else + throw new Error('WebSocketClient: Browser does not support "WebSocket".'); + this.Socket.binaryType = 'arraybuffer'; + this.Socket.addEventListener("open", this.OnOpen.bind(this)); + this.Socket.addEventListener("error", this.OnError.bind(this)); + this.Socket.addEventListener("close", this.OnClose.bind(this)); + this.Socket.addEventListener("message", this.OnMessage.bind(this)); + } + Object.defineProperty(WebSocketClient.prototype, "Connected", { + get: function () { + return this.IsConnected; + }, + enumerable: false, + configurable: true + }); + WebSocketClient.prototype.Send = function (message) { + if (!this.IsConnected) + return; + this.Socket.send(message); + }; + // Handle errors + WebSocketClient.prototype.OnError = function (_ev) { + if (this.IsConnected == true) + this.ErrorCallback("Socket fault."); + else + this.ErrorCallback("Could not connect to server."); + }; + // Change connetion status once connected + WebSocketClient.prototype.OnOpen = function (_ev) { + if (this.Socket.readyState == 1) { + this.IsConnected = true; + this.ConnectCallback(); + } + }; + // Change connetion status on disconnect + WebSocketClient.prototype.OnClose = function (_ev) { + if (this.IsConnected == true && (this.Socket.readyState == 2 || this.Socket.readyState == 3)) { + this.IsConnected = false; + this.DisconnectCallback(); + } + }; + // Handle incomping data + WebSocketClient.prototype.OnMessage = function (ev) { + // Trigger callback + this.DataReadyCallback(ev.data); + }; + return WebSocketClient; +}()); \ No newline at end of file diff --git a/web/js/main.js b/web/js/main.js index f705ea3..b720e50 100644 --- a/web/js/main.js +++ b/web/js/main.js @@ -36,6 +36,8 @@ $(document).ready(function() { localStorage.setItem('qthLatitude', data.qthLatitude); localStorage.setItem('qthLongitude', data.qthLongitude); localStorage.setItem('webServerName', data.webServerName); + localStorage.setItem('audioPort', data.audioPort); + localStorage.setItem('streamEnabled', data.streamEnabled); document.title = 'FM-DX Webserver [' + data.webServerName + ']'; }, @@ -195,7 +197,26 @@ $(document).ready(function() { $('#data-rt1').html(processString(parsedData.rt1, parsedData.rt1_errors)); $('#data-flag').html(''); - const signalValue = signalToggle.is(':checked') ? (parsedData.signal - 11.75) : parsedData.signal; + const signalUnit = localStorage.getItem('signalUnit'); + let signalText = $('#signal-units'); + let signalValue; + + switch (signalUnit) { + case 'dbuv': + signalValue = parsedData.signal - 11.75; + signalText.text('dBµV'); + break; + + case 'dbm': + signalValue = parsedData.signal - 120; + signalText.text('dBm'); + break; + default: + signalValue = parsedData.signal; + signalText.text('dBf'); + break; + } + //const signalValue = signalToggle.is(':checked') ? (parsedData.signal - 11.75) : parsedData.signal; const integerPart = Math.floor(signalValue); const decimalPart = (signalValue - integerPart).toFixed(1).slice(1); // Adjusted this line @@ -205,11 +226,14 @@ $(document).ready(function() { } signalToggle.on("change", function() { - const signalText = $('#signal-units'); - if (signalToggle.prop('checked')) { + const signalText = localStorage.getItem('signalUnit'); + + if (signalText == 'dbuv') { signalText.text('dBµV'); - } else { + } else if (signalText == 'dbf') { signalText.text('dBf'); + } else { + signalText.text('dBm'); } }); diff --git a/web/js/themes.js b/web/js/settings.js similarity index 65% rename from web/js/themes.js rename to web/js/settings.js index e834a26..1cc73c7 100644 --- a/web/js/themes.js +++ b/web/js/settings.js @@ -1,3 +1,4 @@ +/* Themes */ const themes = { theme1: ['#1d1838', '#8069fa'], theme2: ['#381818', '#ff7070'], @@ -30,4 +31,24 @@ $(document).ready(() => { setTheme(selectedTheme); localStorage.setItem('theme', selectedTheme); }); +}); + +/* Signal Units */ +const signalUnits = { + dbf: ['dBf'], + dbuv: ['dBµV'], + dbm: ['dBm'], +}; + +$(document).ready(() => { + const signalSelector = $('#signal-selector'); + + if (localStorage.getItem('signalUnit')) { + signalSelector.val(localStorage.getItem('signalUnit')); + } + + signalSelector.on('change', (event) => { + const selectedSignalUnit = event.target.value; + localStorage.setItem('signalUnit', selectedSignalUnit); + }); }); \ No newline at end of file diff --git a/web/js/webserver.js b/web/js/webserver.js index 35f3df4..00f18a0 100644 --- a/web/js/webserver.js +++ b/web/js/webserver.js @@ -1,3 +1,3 @@ $.getScript('/js/main.js'); $.getScript('/js/modal.js'); -$.getScript('/js/themes.js'); \ No newline at end of file +$.getScript('/js/settings.js'); \ No newline at end of file