diff --git a/drivers/linux-rawmidi.js b/drivers/linux-rawmidi.js new file mode 100644 index 0000000..568ae84 --- /dev/null +++ b/drivers/linux-rawmidi.js @@ -0,0 +1,52 @@ +const path = require("node:path"); +const fs = require("node:fs"); + +const midiDeviceRegExp = new RegExp("^midi[0-9]+$"); + +let openDevice; +let callback; + +module.exports = { + "list": function () { + var deviceNodeList = fs.readdirSync("/dev").filter(d => d.match(midiDeviceRegExp)); + + return deviceNodeList.map(d => path.join("/dev", d)); + }, + "open": function (device, eventCB) { + if (openDevice) { + openDevice["read"].close(); + openDevice["write"].close(); + } + + var deviceName = path.basename(device); + var devicePath = path.join("/dev", deviceName); + + openDevice = { + "read": fs.createReadStream(devicePath), + "write": fs.createWriteStream(devicePath) + }; + + if (eventCB) { + callback = eventCB; + openDevice.read.on("data", function (chunk) { + callback(chunk); + }); + } + }, + "send": function (message) { + if (!openDevice) return; + + var data = Buffer.from(new Uint8Array(message)); + + openDevice["write"].write(data); + }, + "close": function () { + if (!openDevice) return; + + openDevice["read"].close(); + openDevice["write"].close(); + + delete openDevice; + delete callback; + } +}; diff --git a/index.js b/index.js new file mode 100644 index 0000000..55597a0 --- /dev/null +++ b/index.js @@ -0,0 +1,5 @@ +if ("electron" in process.versions) { + require("./main.js"); +} else { + require("./server.js"); +} diff --git a/main.js b/main.js new file mode 100644 index 0000000..11f9d85 --- /dev/null +++ b/main.js @@ -0,0 +1,101 @@ +const path = require("node:path"); +const fs = require("node:fs"); +const electron = require("electron"); + +let window; + +let driver; +let mappings; + +electron.app.on("ready", function () { + window = new electron.BrowserWindow({ + "width": 1280, + "height": 480, + "backgroundColor": "#080C10FF", + "title": "DJ Controller Emulator", + "webPreferences": { + "preload": path.join(__dirname, "preload.js") + }, + "show": false + }); + + window.loadFile(path.join(__dirname, "view", "index.html")); + + window.once("ready-to-show", function () { + window.show(); + }); +}); + +electron.app.on("window-all-closed", function () { + electron.app.quit(); +}); + +electron.ipcMain.handle("load-driver", function (event, driverName) { + if (driver) { + driver.close(); + delete driver; + } + + var driverPath = path.join(__dirname, "drivers", path.basename(driverName) + ".js"); + driver = require(driverPath); + + return true; +}); + +electron.ipcMain.handle("load-mappings", function (event, mappingsName) { + if (mappings) { + delete mappings; + } + + var mappingsPath = path.join(__dirname, "mappings", path.basename(mappingsName) + ".json"); + mappings = JSON.parse(fs.readFileSync(mappingsPath)); + + return true; +}); + +electron.ipcMain.handle("list-devices", function (event) { + return driver.list(); +}); + +electron.ipcMain.handle("open-device", function (event, name) { + return driver.open(name, function (message) { + var byteArray = new Array(...message); + var hexStringifiedMessage = byteArray.map(b => b.toString(16)).map(c => ("00".substring(c.length) + c)).join(" "); + + for (var mappingRegExpString in mappings.inbound) { + if (mappings.inbound.hasOwnProperty(mappingRegExpString)) { + var mappingRegExp = new RegExp(mappingRegExpString); + + var mappingMatches = hexStringifiedMessage.match(mappingRegExp); + if (!mappingMatches) continue; + + var mappingName = mappings.inbound[mappingRegExpString]; + + mappingMatches.shift(); + + var value = parseInt(mappingMatches[0], 16); + + window.webContents.send("signal", mappingName, value); + } + } + }); +}); + +electron.ipcMain.handle("control", function (event, name, value = 0x00) { + if (!driver) return false; + if (!mappings.outbound[name]) return false; + + var message = mappings.outbound[name].map(function (byte) { + if (typeof byte != "number") { + return value; + } else { + return byte; + } + }); + + driver.send(message); +}); + +electron.ipcMain.handle("close-device", function (event) { + return driver.close(); +}); diff --git a/mappings/numark-mixtrackplatinum.json b/mappings/numark-mixtrackplatinum.json new file mode 100644 index 0000000..0ef1062 --- /dev/null +++ b/mappings/numark-mixtrackplatinum.json @@ -0,0 +1,25 @@ +{ + "inbound": { + "bf 44 ([0-9a-f]{2})": "master.volume.left", + "bf 44 [0-9a-f]{2} 45 ([0-9a-f]{2})": "master.volume.right" + }, + "outbound": { + "deck0.load": [144, 16, 127], + "deck1.load": [145, 16, 127], + "deck2.load": [146, 16, 127], + "deck3.load": [147, 16, 127], + "deck0.play_pause": [144, 4, 127], + "deck1.play_pause": [145, 4, 127], + "deck2.play_pause": [146, 4, 127], + "deck3.play_pause": [147, 4, 127], + "deck0.volume": [176, 28, ""], + "deck1.volume": [177, 28, ""], + "deck2.volume": [178, 28, ""], + "deck3.volume": [179, 28, ""], + "deck0.speed": [176, 9, "", 176, 119, ""], + "deck1.speed": [177, 9, "", 177, 119, ""], + "deck2.speed": [178, 9, "", 178, 119, ""], + "deck3.speed": [179, 9, "", 179, 119, ""], + "master.crossfade": [191, 8, ""] + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..760ef15 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "dj-controller-emulator", + "version": "0.0.1", + "description": "A DJ controller emulator", + "main": "index.js", + "repository": "https://gitea.x3f200c.net/X3F200C/DJCtrlEmu", + "author": "X3F200C", + "license": "GPL-3.0", + "dependencies": { + "ws": "^8.14.2" + } +} diff --git a/preload.js b/preload.js new file mode 100644 index 0000000..3f171ef --- /dev/null +++ b/preload.js @@ -0,0 +1,55 @@ +const electron = require("electron"); + +let volumeMeterLeft; +let volumeMeterRight; + +function sendControl(name, value = 0x00) { + electron.ipcRenderer.invoke("control", name, value); +} + +electron.contextBridge.exposeInMainWorld("controller", { + "load": function (deckIndex) { + sendControl("deck" + String(deckIndex) + ".load"); + }, + "play_pause": function (deckIndex) { + sendControl("deck" + String(deckIndex) + ".play_pause"); + }, + "volume": function (deckIndex, value) { + sendControl("deck" + String(deckIndex) + ".volume", value); + }, + "speed": function (deckIndex, value) { + sendControl("deck" + String(deckIndex) + ".speed", value); + }, + "crossfade": function (value) { + sendControl("master.crossfade", value); + } +}); + +electron.ipcRenderer.on("signal", function (event, name, value = 0x00) { + switch (name) { + case "master.volume.left": + volumeMeterLeft.style.height = String(Math.round((value / 80) * 100)) + "%"; + break; + case "master.volume.right": + volumeMeterRight.style.height = String(Math.round((value / 80) * 100)) + "%"; + break; + default: + break; + } +}); + +electron.ipcRenderer.invoke("load-driver", "linux-rawmidi"); +electron.ipcRenderer.invoke("load-mappings", "numark-mixtrackplatinum"); + +(async function () { + var devices = await electron.ipcRenderer.invoke("list-devices"); + + if (devices.length > 0) { + electron.ipcRenderer.invoke("open-device", devices[0]); + } +})(); + +window.onload = function (event) { + volumeMeterLeft = document.querySelector("#volume-meter-left"); + volumeMeterRight = document.querySelector("#volume-meter-right"); +}; diff --git a/server.js b/server.js new file mode 100644 index 0000000..9568ef5 --- /dev/null +++ b/server.js @@ -0,0 +1,153 @@ +const path = require("node:path"); +const fs = require("node:fs"); +const http = require("node:http"); +const ws = require("ws"); + +let driver; +let mappings; + +const webServer = new http.Server(); + +const socketServer = new ws.WebSocketServer({ + "noServer": true +}); + +function sendToClients(message) { + socketServer.clients.forEach(function (client) { + if (client.readyState === ws.OPEN) { + client.send(message); + } + }); +} + +webServer.on("request", function (request, response) { + switch (request.method) { + case "GET": + switch (request.url) { + default: + var filePath = path.join(__dirname, "view", request.url); + + if (!fs.existsSync(filePath)) { + response.writeHead(404); + return response.end("No entry. "); + } + + if (fs.statSync(filePath).isDirectory()) { + var entryPath = path.join(filePath, "index.html"); + + if (!fs.existsSync(entryPath)) { + response.writeHead(404); + return response.end("No entry. "); + } + + response.writeHead(200); + fs.createReadStream(entryPath).pipe(response); + } else { + response.writeHead(200); + fs.createReadStream(filePath).pipe(response); + } + break; + } + break; + default: + response.writeHead(404); + return response.end("No entry. "); + break; + } +}); + +webServer.on("upgrade", function (request, socket, head) { + if (request.url === "/socket") { + socketServer.handleUpgrade(request, socket, head, function (ws) { + socketServer.emit("connection", ws, request); + }); + } else { + socket.destroy(); + } +}); + +socketServer.on("connection", function (socket) { + socket.on("error", console.error); + + socket.on("message", function (event) { + var data = JSON.parse(event); + + switch (data.event) { + case "load-driver": + if (driver) { + driver.close(); + delete driver; + } + + var driverPath = path.join(__dirname, "drivers", path.basename(data.data.name) + ".js"); + driver = require(driverPath); + break; + case "load-mappings": + if (mappings) { + delete mappings; + } + + var mappingsPath = path.join(__dirname, "mappings", path.basename(data.data.name) + ".json"); + mappings = JSON.parse(fs.readFileSync(mappingsPath)); + break; + case "list-devices": + socket.send(JSON.stringify({ + "event": "device-list", + "data": { + "devices": driver.list() + } + })); + break; + case "open-device": + driver.open(data.data.name, function (message) { + var byteArray = new Array(...message); + var hexStringifiedMessage = byteArray.map(b => b.toString(16)).map(c => ("00".substring(c.length) + c)).join(" "); + + for (var mappingRegExpString in mappings.inbound) { + if (mappings.inbound.hasOwnProperty(mappingRegExpString)) { + var mappingRegExp = new RegExp(mappingRegExpString); + + var mappingMatches = hexStringifiedMessage.match(mappingRegExp); + if (!mappingMatches) continue; + + var mappingName = mappings.inbound[mappingRegExpString]; + + mappingMatches.shift(); + + var value = parseInt(mappingMatches[0], 16); + + sendToClients(JSON.stringify({ + "event": "signal", + "data": { + "name": mappingName, + "value": value + } + })); + } + } + }); + break; + case "control": + if (!driver) return; + if (!mappings.outbound[data.data.name]) return; + + var message = mappings.outbound[data.data.name].map(function (byte) { + if (typeof byte != "number") { + return data.data.value; + } else { + return byte; + } + }); + + driver.send(message); + break; + case "close-device": + driver.close(); + break; + default: + + } + }); +}); + +webServer.listen(23272); diff --git a/view/assets/scripts/main.js b/view/assets/scripts/main.js new file mode 100644 index 0000000..c1f868e --- /dev/null +++ b/view/assets/scripts/main.js @@ -0,0 +1,105 @@ +if (!("controller" in window)) { + let socket; + + let volumeMeterLeft; + let volumeMeterRight; + + function connect(url) { + if (socket) { + socket.close(); + } + + socket = new WebSocket(url); + + socket.onmessage = function (event) { + var data = JSON.parse(event.data); + + switch (data.event) { + case "device-list": + if (data.data.devices.length > 0) { + socket.send(JSON.stringify({ + "event": "open-device", + "data": { + "name": data.data.devices[0] + } + })); + } + break; + case "signal": + onSignal(event, data.data.name, data.data.value); + break; + default: + break; + } + }; + + socket.onopen = function () { + socket.send(JSON.stringify({ + "event": "load-driver", + "data": { + "name": "linux-rawmidi" + } + })); + socket.send(JSON.stringify({ + "event": "load-mappings", + "data": { + "name": "numark-mixtrackplatinum" + } + })); + socket.send(JSON.stringify({ + "event": "list-devices" + })); + }; + } + + function sendControl(name, value = 0x00) { + socket.send(JSON.stringify({ + "event": "control", + "data": { + "name": name, + "value": value + } + })); + } + + window.controller = { + "load": function (deckIndex) { + sendControl("deck" + String(deckIndex) + ".load"); + }, + "play_pause": function (deckIndex) { + sendControl("deck" + String(deckIndex) + ".play_pause"); + }, + "volume": function (deckIndex, value) { + sendControl("deck" + String(deckIndex) + ".volume", value); + }, + "speed": function (deckIndex, value) { + sendControl("deck" + String(deckIndex) + ".speed", value); + }, + "pad": function (deckIndex, padIndex) { + sendControl("deck" + String(deckIndex) + ".pad" + String(padIndex)); + }, + "crossfade": function (value) { + sendControl("master.crossfade", value); + } + }; + + var onSignal = function (event, name, value = 0x00) { + switch (name) { + case "master.volume.left": + volumeMeterLeft.style.height = String(Math.round((value / 80) * 100)) + "%"; + break; + case "master.volume.right": + volumeMeterRight.style.height = String(Math.round((value / 80) * 100)) + "%"; + break; + default: + break; + } + }; + + window.onload = function (event) { + volumeMeterLeft = document.querySelector("#volume-meter-left"); + volumeMeterRight = document.querySelector("#volume-meter-right"); + + connect("ws://" + window.location.host + "/socket"); + }; +} diff --git a/view/assets/stylesheets/style.css b/view/assets/stylesheets/style.css new file mode 100644 index 0000000..f748427 --- /dev/null +++ b/view/assets/stylesheets/style.css @@ -0,0 +1,114 @@ +:root { + --text-color: #FDFDFD; + + background-color: rgba(8, 12, 16, 1.0); +} + +:root, body, main, #sections { + width: 100%; + height: 100%; +} + +body { + margin: 0px; +} + +button { + min-width: 96px; + min-height: 64px; +} + +#sections, .deck, #mixer, #volume, #volume-meters, .volume-meter, .deck-pads { + display: flex; + justify-content: space-between; +} + +#sections, #volume, #volume-meters, .deck-pads { + flex-direction: row; +} + +#mixer, .deck { + flex-direction: column; + height: 100%; +} + +.deck { + align-items: start; +} + +.reverse.deck { + align-items: end; +} + +#volume { + /* flex-grow: 1; */ +} + +.volume-slider, .speed-slider { + appearance: slider-vertical !important; + /* width: 8px; + height: 100%; */ +} + +.volume-meter { + flex-direction: column-reverse; + flex-grow: 1; + background-image: linear-gradient(0deg, lime 0%, orange 80%, red 100%); + width: 16px; +} + +.volume-meter-level { + width: 100%; + background-color: white; + mix-blend-mode: multiply; + transition: height 0.05s ease-in-out; +} + +.silence-meter-level { + flex-grow: 1; + width: 100%; + background-color: grey; + mix-blend-mode: multiply; +} + +.deck-pads, .play { + margin: 16px; +} + +.deck-pads { + flex-wrap: wrap; + width: 70%; + aspect-ratio: 4 / 2; +} + +.deck-pad { + width: 25%; + aspect-ratio: 1 / 1; +} + +/* input[type="range"] { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + height: 8px; +} + +input[type="range"]::-webkit-slider-thumb { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background: var(--text-color); + width: 16px; + height: 48px; + border-radius: 0px; +} + +input[type="range"]::-moz-range-thumb { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background: var(--text-color); + width: 16px; + height: 48px; + border-radius: 0px; +} */ diff --git a/view/index.html b/view/index.html new file mode 100644 index 0000000..9448df6 --- /dev/null +++ b/view/index.html @@ -0,0 +1,85 @@ + + + + + DJ Controller Emulator + + + + + +
+
+
+
+ +
+
+ +
+
+
+ + + + + + + + +
+ + +
+
+
+
+
+ +
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+
+ +
+ +
+
+
+ +
+
+ +
+
+
+ + + + + + + + +
+ + +
+
+
+
+ +