commit bd7e6ff0190d3dc7b3aa143fb3e55817a25c60d6 Author: km0 Date: Sun Mar 19 17:29:51 2023 +0100 first test audio api diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..65d7854 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.env +package-lock.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..72e7d92 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Teletap + +A small playground for networked sound experiments with Web Audio API. + + +## Setup + +Clone the repo , move to the directory and install the dependencies using your favourite package manager ([npm](https://www.npmjs.com/), [yarn](https://yarnpkg.com/), etc.). + +Note that to run the app you will need to install [Node.js](https://nodejs.org/en/), either on your machine or on a VPS. + +For example with npm: + +``` +git clone https://git.xpub.nl/kamo/drw +cd drw +npm install +``` + +To start the application run + +`node server.js` + +or in alternative + +`npm start` + +Then open your browser to `localhost:3000` and there you can tap on the screen to generate sounds.. + +If you open another tab and navigate to `localhost:3000/destination`, there you will receive the sounds. Note that you will not hear anything until you press on the `Join Audio` button. This is because for security policies, browsers don't play any sound before a user's interaction. + +This app uses the same architecture of [DRw](https://git.xpub.nl/kamo/drw). Find more info there! diff --git a/cover.jpg b/cover.jpg new file mode 100644 index 0000000..ba3f12b Binary files /dev/null and b/cover.jpg differ diff --git a/img/pingpong.jpg b/img/pingpong.jpg new file mode 100644 index 0000000..93a7288 Binary files /dev/null and b/img/pingpong.jpg differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..d27a1c8 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "sal8", + "version": "1.0.0", + "description": "collective drawings", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "author": "", + "license": "ISC", + "dependencies": { + "dotenv": "^16.0.3", + "express": "^4.18.2", + "hbs": "^4.2.0", + "ws": "^8.11.0" + } +} diff --git a/public/audioDestination.js b/public/audioDestination.js new file mode 100644 index 0000000..23f328b --- /dev/null +++ b/public/audioDestination.js @@ -0,0 +1,131 @@ +const audioContext = new AudioContext(); + +const tap = document.querySelector('.container') +const address = document.querySelector('#address').innerHTML + +const socket = new ReconnectingWebSocket( + location.origin.replace(/^http/, "ws") + address + +); + +socket.onopen = (event) => { + socket.send(JSON.stringify({ type: "hello" })); + console.log("Connected as destination!"); +}; + + + + +let noiseDuration = 0.05 +const playNoise = (bandHz = 1000, time = 0) => { + + const bufferSize = audioContext.sampleRate * noiseDuration + + // Create an empty buffer + const noiseBuffer = new AudioBuffer({ + length: bufferSize, + sampleRate: audioContext.sampleRate, + }) + + // Fill the buffer with noise + const data = noiseBuffer.getChannelData(0); + for (let i = 0; i < bufferSize; i++) { + data[i] = Math.random() * 2 - 1; + } + + // Create a buffer source from data + const noise = new AudioBufferSourceNode(audioContext, { + buffer: noiseBuffer, + }) + + // Filter the output + const bandpass = new BiquadFilterNode(audioContext, { + type: "bandpass", + frequency: bandHz + }) + + noise.connect(bandpass).connect(audioContext.destination); + noise.start(time); + +} + + + const playPulse = (freq=440, lfoFreq=30, duration=1, time=0) => { + + const osc = new OscillatorNode(audioContext, { + type: "square", + frequency: freq + }) + + const amp = new GainNode(audioContext, { + value: 0 + }) + + const lfo = new OscillatorNode(audioContext, { + type: "sine", + frequency: lfoFreq + }) + + lfo.connect(amp.gain) + osc.connect(amp).connect(audioContext.destination) + lfo.start() + osc.start(time) + // osc.stop(time + duration) + + } + + +const spawnGradient = (x, y) => { + const gradient = document.createElement('div') + gradient.classList.add('gradient') + gradient.style.translate = `${x}px ${y}px` + gradient.style.scale = 0 + + let red = x / tap.clientWidth * 255 + let green = y / tap.clientHeight * 255 + let blue = 0 + + gradient.style.background = `radial-gradient(circle, rgba(${red},${green},${blue},1) 0%, rgba(${red},${green},${blue},0) 25%)` + + tap.appendChild(gradient) + grow(gradient) + +} + +const grow = (el) => { + let scale = Number(el.style.scale) || 0 + el.style.scale = scale + 0.1 + requestAnimationFrame(()=> grow(el)) + +} + + + + +const emit = (x, y) => { + playPulse(x, y * 0.01) + spawnGradient(x, y) + } + + +const join = document.querySelector('#join') +join.addEventListener('click', ()=>{ + playNoise() +}) + + + + +socket.onmessage = (event) => { + let message; + try { + message = JSON.parse(event.data); + } catch (e) {} + + console.log("received a message! ", message) + if (message?.type == 'pulse'){ + emit(message.x, message.y) + } +}; + + diff --git a/public/draw.js b/public/draw.js new file mode 100644 index 0000000..ad99ddb --- /dev/null +++ b/public/draw.js @@ -0,0 +1,109 @@ +// Great resource from https://stackoverflow.com/a/40700068 +// Thank you ConnorFan + +var strokeWidth = 8; +var bufferSize; + +var svgElement = document.getElementById("svgElement"); +var rect = svgElement.getBoundingClientRect(); +var path = null; +var strPath; +var buffer = []; // Contains the last positions of the mouse cursor + +const startDrawing = (e) => { + e.preventDefault(); + // console.log("start"); + bufferSize = 2; + path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("fill", "none"); + path.setAttribute("stroke", "currentColor"); + path.setAttribute("stroke-width", strokeWidth); + buffer = []; + var pt = getMousePosition(e); + appendToBuffer(pt); + strPath = "M" + pt.x + " " + pt.y; + path.setAttribute("d", strPath); + svgElement.appendChild(path); +}; + +const draw = (e) => { + e.preventDefault(); + if (path) { + // console.log("draw"); + appendToBuffer(getMousePosition(e)); + updateSvgPath(); + } +}; + +const stopDrawing = () => { + if (path) { + // console.log("stop"); + path = null; + } +}; + +var getMousePosition = function (e) { + return { + x: (e.pageX || e?.changedTouches[0]?.pageX) - rect.left, + y: (e.pageY || e?.changedTouches[0]?.pageY) - rect.top, + }; +}; + +var appendToBuffer = function (pt) { + buffer.push(pt); + while (buffer.length > bufferSize) { + buffer.shift(); + } +}; + +// Calculate the average point, starting at offset in the buffer +var getAveragePoint = function (offset) { + var len = buffer.length; + if (len % 2 === 1 || len >= bufferSize) { + var totalX = 0; + var totalY = 0; + var pt, i; + var count = 0; + for (i = offset; i < len; i++) { + count++; + pt = buffer[i]; + totalX += pt.x; + totalY += pt.y; + } + return { + x: totalX / count, + y: totalY / count, + }; + } + return null; +}; + +var updateSvgPath = function () { + var pt = getAveragePoint(0); + + if (pt) { + // Get the smoothed part of the path that will not change + strPath += " L" + pt.x + " " + pt.y; + + // Get the last part of the path (close to the current mouse position) + // This part will change if the mouse moves again + var tmpPath = ""; + for (var offset = 2; offset < buffer.length; offset += 2) { + pt = getAveragePoint(offset); + tmpPath += " L" + pt.x + " " + pt.y; + } + + // Set the complete current path coordinates + path.setAttribute("d", strPath + tmpPath); + } +}; + +svgElement.addEventListener("mousedown", (e) => startDrawing(e)); +svgElement.addEventListener("touchstart", (e) => startDrawing(e), { passive: false }); + +svgElement.addEventListener("mousemove", (e) => draw(e)); +svgElement.addEventListener("touchmove", (e) => draw(e), { passive: false }); + +svgElement.addEventListener("mouseup", () => stopDrawing()); +svgElement.addEventListener("mouseleave", () => stopDrawing()); +svgElement.addEventListener("touchend", () => stopDrawing()); diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..66c546c --- /dev/null +++ b/public/style.css @@ -0,0 +1,21 @@ +.container { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100vh; +} + +.container.destination { + pointer-events: none; +} + +.gradient { + position: absolute; + top: 0; + left: 0; + width: 50px; + height: 50px; + display: inline-block; + pointer-events: none; +} diff --git a/public/teletap.js b/public/teletap.js new file mode 100644 index 0000000..9583999 --- /dev/null +++ b/public/teletap.js @@ -0,0 +1,100 @@ +const tap = document.querySelector('.container') +const address = document.querySelector('#address').innerHTML +const socket = new ReconnectingWebSocket(location.origin.replace(/^http/, "ws") + address); +const audioContext = new AudioContext(); + +let noiseDuration = 0.05 +const playNoise = (bandHz = 1000, time = 0) => { + + const bufferSize = audioContext.sampleRate * noiseDuration + + // Create an empty buffer + const noiseBuffer = new AudioBuffer({ + length: bufferSize, + sampleRate: audioContext.sampleRate, + }) + + // Fill the buffer with noise + const data = noiseBuffer.getChannelData(0); + for (let i = 0; i < bufferSize; i++) { + data[i] = Math.random() * 2 - 1; + } + + // Create a buffer source from data + const noise = new AudioBufferSourceNode(audioContext, { + buffer: noiseBuffer, + }) + + // Filter the output + const bandpass = new BiquadFilterNode(audioContext, { + type: "bandpass", + frequency: bandHz + }) + + noise.connect(bandpass).connect(audioContext.destination); + noise.start(time); + +} + + + const playPulse = (freq=440, lfoFreq=30, duration=1, time=0) => { + + const osc = new OscillatorNode(audioContext, { + type: "square", + frequency: freq + }) + + const amp = new GainNode(audioContext, { + value: 0 + }) + + const lfo = new OscillatorNode(audioContext, { + type: "sine", + frequency: lfoFreq + }) + + lfo.connect(amp.gain) + osc.connect(amp).connect(audioContext.destination) + lfo.start() + osc.start(time) + // osc.stop(time + duration) + + } + + +const spawnGradient = (x, y) => { + const gradient = document.createElement('div') + gradient.classList.add('gradient') + gradient.style.translate = `${x}px ${y}px` + gradient.style.scale = 0 + + let red = x / tap.clientWidth * 255 + let green = y / tap.clientHeight * 255 + let blue = 0 + + gradient.style.background = `radial-gradient(circle, rgba(${red},${green},${blue},1) 0%, rgba(${red},${green},${blue},0) 25%)` + + tap.appendChild(gradient) + grow(gradient) + +} + +const grow = (el) => { + let scale = Number(el.style.scale) || 0 + el.style.scale = scale + 0.1 + requestAnimationFrame(()=> grow(el)) + +} + + + + +tap.addEventListener("click", (e) => { + playPulse(e.clientX, e.clientY * 0.01) + spawnGradient(e.clientX, e.clientY) + console.log('sending pulse') + socket.send(JSON.stringify({type: 'pulse', x: e.clientX, y: e.clientY })) + }) + + + diff --git a/public/wss.js b/public/wss.js new file mode 100644 index 0000000..d29bc58 --- /dev/null +++ b/public/wss.js @@ -0,0 +1,382 @@ +// MIT License: +// +// Copyright (c) 2010-2012, Joe Walnes +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +/** + * This behaves like a WebSocket in every way, except if it fails to connect, + * or it gets disconnected, it will repeatedly poll until it successfully connects + * again. + * + * It is API compatible, so when you have: + * ws = new WebSocket('ws://....'); + * you can replace with: + * ws = new ReconnectingWebSocket('ws://....'); + * + * The event stream will typically look like: + * onconnecting + * onopen + * onmessage + * onmessage + * onclose // lost connection + * onconnecting + * onopen // sometime later... + * onmessage + * onmessage + * etc... + * + * It is API compatible with the standard WebSocket API, apart from the following members: + * + * - `bufferedAmount` + * - `extensions` + * - `binaryType` + * + * Latest version: https://github.com/joewalnes/reconnecting-websocket/ + * - Joe Walnes + * + * Syntax + * ====== + * var socket = new ReconnectingWebSocket(url, protocols, options); + * + * Parameters + * ========== + * url - The url you are connecting to. + * protocols - Optional string or array of protocols. + * options - See below + * + * Options + * ======= + * Options can either be passed upon instantiation or set after instantiation: + * + * var socket = new ReconnectingWebSocket(url, null, { debug: true, reconnectInterval: 4000 }); + * + * or + * + * var socket = new ReconnectingWebSocket(url); + * socket.debug = true; + * socket.reconnectInterval = 4000; + * + * debug + * - Whether this instance should log debug messages. Accepts true or false. Default: false. + * + * automaticOpen + * - Whether or not the websocket should attempt to connect immediately upon instantiation. The socket can be manually opened or closed at any time using ws.open() and ws.close(). + * + * reconnectInterval + * - The number of milliseconds to delay before attempting to reconnect. Accepts integer. Default: 1000. + * + * maxReconnectInterval + * - The maximum number of milliseconds to delay a reconnection attempt. Accepts integer. Default: 30000. + * + * reconnectDecay + * - The rate of increase of the reconnect delay. Allows reconnect attempts to back off when problems persist. Accepts integer or float. Default: 1.5. + * + * timeoutInterval + * - The maximum time in milliseconds to wait for a connection to succeed before closing and retrying. Accepts integer. Default: 2000. + * + */ +(function (global, factory) { + if (typeof define === "function" && define.amd) { + define([], factory); + } else if (typeof module !== "undefined" && module.exports) { + module.exports = factory(); + } else { + global.ReconnectingWebSocket = factory(); + } +})(this, function () { + if (!("WebSocket" in window)) { + return; + } + + function ReconnectingWebSocket(url, protocols, options) { + // Default settings + var settings = { + /** Whether this instance should log debug messages. */ + debug: false, + + /** Whether or not the websocket should attempt to connect immediately upon instantiation. */ + automaticOpen: true, + + /** The number of milliseconds to delay before attempting to reconnect. */ + reconnectInterval: 1000, + /** The maximum number of milliseconds to delay a reconnection attempt. */ + maxReconnectInterval: 30000, + /** The rate of increase of the reconnect delay. Allows reconnect attempts to back off when problems persist. */ + reconnectDecay: 1.5, + + /** The maximum time in milliseconds to wait for a connection to succeed before closing and retrying. */ + timeoutInterval: 2000, + + /** The maximum number of reconnection attempts to make. Unlimited if null. */ + maxReconnectAttempts: null, + + /** The binary type, possible values 'blob' or 'arraybuffer', default 'blob'. */ + binaryType: "blob", + }; + if (!options) { + options = {}; + } + + // Overwrite and define settings with options if they exist. + for (var key in settings) { + if (typeof options[key] !== "undefined") { + this[key] = options[key]; + } else { + this[key] = settings[key]; + } + } + + // These should be treated as read-only properties + + /** The URL as resolved by the constructor. This is always an absolute URL. Read only. */ + this.url = url; + + /** The number of attempted reconnects since starting, or the last successful connection. Read only. */ + this.reconnectAttempts = 0; + + /** + * The current state of the connection. + * Can be one of: WebSocket.CONNECTING, WebSocket.OPEN, WebSocket.CLOSING, WebSocket.CLOSED + * Read only. + */ + this.readyState = WebSocket.CONNECTING; + + /** + * A string indicating the name of the sub-protocol the server selected; this will be one of + * the strings specified in the protocols parameter when creating the WebSocket object. + * Read only. + */ + this.protocol = null; + + // Private state variables + + var self = this; + var ws; + var forcedClose = false; + var timedOut = false; + var eventTarget = document.createElement("div"); + + // Wire up "on*" properties as event handlers + + eventTarget.addEventListener("open", function (event) { + self.onopen(event); + }); + eventTarget.addEventListener("close", function (event) { + self.onclose(event); + }); + eventTarget.addEventListener("connecting", function (event) { + self.onconnecting(event); + }); + eventTarget.addEventListener("message", function (event) { + self.onmessage(event); + }); + eventTarget.addEventListener("error", function (event) { + self.onerror(event); + }); + + // Expose the API required by EventTarget + + this.addEventListener = eventTarget.addEventListener.bind(eventTarget); + this.removeEventListener = eventTarget.removeEventListener.bind(eventTarget); + this.dispatchEvent = eventTarget.dispatchEvent.bind(eventTarget); + + /** + * This function generates an event that is compatible with standard + * compliant browsers and IE9 - IE11 + * + * This will prevent the error: + * Object doesn't support this action + * + * http://stackoverflow.com/questions/19345392/why-arent-my-parameters-getting-passed-through-to-a-dispatched-event/19345563#19345563 + * @param s String The name that the event should use + * @param args Object an optional object that the event will use + */ + function generateEvent(s, args) { + var evt = document.createEvent("CustomEvent"); + evt.initCustomEvent(s, false, false, args); + return evt; + } + + this.open = function (reconnectAttempt) { + ws = new WebSocket(self.url, protocols || []); + ws.binaryType = this.binaryType; + + if (reconnectAttempt) { + if ( + this.maxReconnectAttempts && + this.reconnectAttempts > this.maxReconnectAttempts + ) { + return; + } + } else { + eventTarget.dispatchEvent(generateEvent("connecting")); + this.reconnectAttempts = 0; + } + + if (self.debug || ReconnectingWebSocket.debugAll) { + console.debug("ReconnectingWebSocket", "attempt-connect", self.url); + } + + var localWs = ws; + var timeout = setTimeout(function () { + if (self.debug || ReconnectingWebSocket.debugAll) { + console.debug("ReconnectingWebSocket", "connection-timeout", self.url); + } + timedOut = true; + localWs.close(); + timedOut = false; + }, self.timeoutInterval); + + ws.onopen = function (event) { + clearTimeout(timeout); + if (self.debug || ReconnectingWebSocket.debugAll) { + console.debug("ReconnectingWebSocket", "onopen", self.url); + } + self.protocol = ws.protocol; + self.readyState = WebSocket.OPEN; + self.reconnectAttempts = 0; + var e = generateEvent("open"); + e.isReconnect = reconnectAttempt; + reconnectAttempt = false; + eventTarget.dispatchEvent(e); + }; + + ws.onclose = function (event) { + clearTimeout(timeout); + ws = null; + if (forcedClose) { + self.readyState = WebSocket.CLOSED; + eventTarget.dispatchEvent(generateEvent("close")); + } else { + self.readyState = WebSocket.CONNECTING; + var e = generateEvent("connecting"); + e.code = event.code; + e.reason = event.reason; + e.wasClean = event.wasClean; + eventTarget.dispatchEvent(e); + if (!reconnectAttempt && !timedOut) { + if (self.debug || ReconnectingWebSocket.debugAll) { + console.debug("ReconnectingWebSocket", "onclose", self.url); + } + eventTarget.dispatchEvent(generateEvent("close")); + } + + var timeout = + self.reconnectInterval * + Math.pow(self.reconnectDecay, self.reconnectAttempts); + setTimeout( + function () { + self.reconnectAttempts++; + self.open(true); + }, + timeout > self.maxReconnectInterval ? self.maxReconnectInterval : timeout + ); + } + }; + ws.onmessage = function (event) { + if (self.debug || ReconnectingWebSocket.debugAll) { + console.debug("ReconnectingWebSocket", "onmessage", self.url, event.data); + } + var e = generateEvent("message"); + e.data = event.data; + eventTarget.dispatchEvent(e); + }; + ws.onerror = function (event) { + if (self.debug || ReconnectingWebSocket.debugAll) { + console.debug("ReconnectingWebSocket", "onerror", self.url, event); + } + eventTarget.dispatchEvent(generateEvent("error")); + }; + }; + + // Whether or not to create a websocket upon instantiation + if (this.automaticOpen == true) { + this.open(false); + } + + /** + * Transmits data to the server over the WebSocket connection. + * + * @param data a text string, ArrayBuffer or Blob to send to the server. + */ + this.send = function (data) { + if (ws) { + if (self.debug || ReconnectingWebSocket.debugAll) { + console.debug("ReconnectingWebSocket", "send", self.url, data); + } + return ws.send(data); + } else { + throw "INVALID_STATE_ERR : Pausing to reconnect websocket"; + } + }; + + /** + * Closes the WebSocket connection or connection attempt, if any. + * If the connection is already CLOSED, this method does nothing. + */ + this.close = function (code, reason) { + // Default CLOSE_NORMAL code + if (typeof code == "undefined") { + code = 1000; + } + forcedClose = true; + if (ws) { + ws.close(code, reason); + } + }; + + /** + * Additional public API method to refresh the connection if still open (close, re-open). + * For example, if the app suspects bad data / missed heart beats, it can try to refresh. + */ + this.refresh = function () { + if (ws) { + ws.close(); + } + }; + } + + /** + * An event listener to be called when the WebSocket connection's readyState changes to OPEN; + * this indicates that the connection is ready to send and receive data. + */ + ReconnectingWebSocket.prototype.onopen = function (event) {}; + /** An event listener to be called when the WebSocket connection's readyState changes to CLOSED. */ + ReconnectingWebSocket.prototype.onclose = function (event) {}; + /** An event listener to be called when a connection begins being attempted. */ + ReconnectingWebSocket.prototype.onconnecting = function (event) {}; + /** An event listener to be called when a message is received from the server. */ + ReconnectingWebSocket.prototype.onmessage = function (event) {}; + /** An event listener to be called when an error occurs. */ + ReconnectingWebSocket.prototype.onerror = function (event) {}; + + /** + * Whether all instances of ReconnectingWebSocket should log debug messages. + * Setting this to true is the equivalent of setting all instances of ReconnectingWebSocket.debug to true. + */ + ReconnectingWebSocket.debugAll = false; + + ReconnectingWebSocket.CONNECTING = WebSocket.CONNECTING; + ReconnectingWebSocket.OPEN = WebSocket.OPEN; + ReconnectingWebSocket.CLOSING = WebSocket.CLOSING; + ReconnectingWebSocket.CLOSED = WebSocket.CLOSED; + + return ReconnectingWebSocket; +}); diff --git a/server.js b/server.js new file mode 100644 index 0000000..e700c18 --- /dev/null +++ b/server.js @@ -0,0 +1,124 @@ +// Import dependencies +const { WebSocket, WebSocketServer } = require("ws"); +const express = require("express"); +const dotenv = require("dotenv"); +dotenv.config(); + +// Setup Environmental Variables +const PORT = process.env.PORT || 3000; +const PREFIX = process.env.PREFIX || ""; +const PUBLIC = process.env.PUBLIC || ""; + +// Setup Express Router +// Create the routes of the application +// Here are two pages and a wildcard: +// at / there is the index.html page, where to draw +// at /destination there is the destination.html page, where to receive the drawings +// the wildcard /* serves the static files from the public folder + +const router = express.Router(); +const routes = (app) => { + app.get("/", (req, res) => { + res.render("index", { + address: PREFIX, + }); + }); + app.get("/destination", (req, res) => { + res.render("destination", { + address: PREFIX, + }); + }); + app.get("/*", (req, res) => { + res.sendFile(req.url, { root: "public" }); + }); + return app; +}; + +// Setup the Express server +const server = express() + .set("view engine", "html") + .engine("html", require("hbs").__express) + .use(PREFIX, routes(router, {})) + .use(express.static("public")) + .listen(PORT, () => console.log(`Listening on ${PORT}`)); + +// Setup the Websocket server on top of the Express one +const wss = new WebSocketServer({ server, clientTracking: true }); + +// Global variables to manage the connected Destination and User clients +let DESTINATIONS = new Set(); +let USERS = new Set(); +var theme = ""; + +// The message processor defines which function is associated to every websocket message type. +// It map a type to a function, passing some optional parameters such as the message itself and the websocket client that sent it. +// for example an incoming message like {type: hello} will trigger the registerDest(ws, msg) function. +// In this way to add message types and functionalities gets easier, and avoid long chain of if-else statements. + +const messageProcessor = { + default: (ws, msg) => unknownMsg(msg), + hello: (ws, msg) => registerDest(ws, msg), + pulse: (ws, msg) => toDest(msg) +}; + +// Message processor functions + +// Default function, to cactch unkown message types +const unknownMsg = (msg) => { + console.log("Unknown message type..."); + console.log(msg); +}; + +// Add the ws client in the destinations set +// Removing it when it disconnets +const registerDest = (ws, msg) => { + console.log("Destination client connected"); + DESTINATIONS.add(ws); + ws.on("close", () => { + DESTINATIONS.delete(ws); + }); +}; + +// Send a message to all the connected Destinations +const toDest = (msg) => { + let message = JSON.stringify(msg); + DESTINATIONS.forEach((DESTINATION) => { + if (DESTINATION?.readyState === WebSocket.OPEN) { + DESTINATION.send(message); + } + }); +}; + +// Send a message to all the connected Users +const broadcast = (msg) => { + let message = JSON.stringify(msg); + console.log('broadcasting: ', message) + for (const user of USERS.values()) { + if (user?.readyState === WebSocket.OPEN && !DESTINATIONS.has(user)) { + user.send(message); + } + } +}; + +// Websocket events listener +wss.on("connection", (ws) => { + USERS.add(ws); + ws.send(JSON.stringify({ type: "theme", theme: theme })); + + ws.on("message", (data) => { + // Parse the incoming data safely + let message; + try { + message = JSON.parse(data); + } catch (e) {} + + // Call the message processor, eventually falling back to use the default function if the type is not defined. + if (message) { + (messageProcessor[message?.type] || messageProcessor.default)(ws, message); + } + }); + + ws.on("close", () => { + USERS.delete(ws); + }); +}); diff --git a/views/destination.html b/views/destination.html new file mode 100644 index 0000000..09c6d23 --- /dev/null +++ b/views/destination.html @@ -0,0 +1,18 @@ + + + + + + + Draw draw draw + + + + + +
+ {{address}} + + + + diff --git a/views/index.html b/views/index.html new file mode 100644 index 0000000..a6cf2e1 --- /dev/null +++ b/views/index.html @@ -0,0 +1,19 @@ + + + + + + + Draw draw draw + + + + + + + {{address}} +
+ + + + diff --git a/views/wander.html b/views/wander.html new file mode 100644 index 0000000..c2e1b1c --- /dev/null +++ b/views/wander.html @@ -0,0 +1,93 @@ + + + + + + + Draw draw draw + + + + +

Display

+
+ +
+ + + +