diff --git a/.gitignore b/.gitignore index 612f1fb..413ee39 100644 --- a/.gitignore +++ b/.gitignore @@ -292,3 +292,4 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +/.vscode \ No newline at end of file diff --git a/client/client.js b/client/client.js index ed5d26c..447adc3 100644 --- a/client/client.js +++ b/client/client.js @@ -13,8 +13,9 @@ async function boot() { // Run the first time boot sequence await firstTimeBoot(); } + // Initialize the socket connection with the server - return initSocketConnection(); + return initSocketConnection(localNodeConfig); } /** @@ -30,6 +31,7 @@ async function firstTimeBoot() { updateId(localNodeConfig.node.id); console.log("Updated the config with the new node ID"); // TODO - Create the config file with the ID given and replace the update above + // TODO - Check if the system is linux or windows and set the 'type' param in DAB // TODO - Implement web server so users can update radio systems easily // TODO - Implement logic to check if the presets are set @@ -39,7 +41,7 @@ async function firstTimeBoot() { // Boot the client application boot().then((openSocket) => { - initSocketListeners(openSocket); + initSocketListeners(openSocket, localNodeConfig); //console.log(openSocket, "Open socket"); setTimeout(() => {sendNodeUpdate(openSocket);}, 2500); }) \ No newline at end of file diff --git a/client/discordAudioBot/dab.mjs b/client/discordAudioBot/dab.mjs new file mode 100644 index 0000000..ce7f033 --- /dev/null +++ b/client/discordAudioBot/dab.mjs @@ -0,0 +1,123 @@ +import { + NoSubscriberBehavior, + StreamType, + createAudioPlayer, + createAudioResource, + entersState, + AudioPlayerStatus, + VoiceConnectionStatus, + joinVoiceChannel, +} from '@discordjs/voice'; + +import { GatewayIntentBits } from 'discord-api-types/v10'; + +import { Client, Events } from 'discord.js'; + +import prism_media from 'prism-media'; +const { FFmpeg } = prism_media; + +const device = "VoiceMeeter VAIO3 Output (VB-Audio VoiceMeeter VAIO3)", maxTransmissionGap = 500, type = "dshow"; + +const player = createAudioPlayer({ + behaviors: { + noSubscriber: NoSubscriberBehavior.Play, + maxMissedFrames: Math.round(maxTransmissionGap / 20), + }, +}); + +function attachRecorder() { + player.play( + createAudioResource( + new FFmpeg({ + args: [ + '-analyzeduration', + '0', + '-loglevel', + '0', + '-f', + type, + '-i', + type === 'dshow' ? `audio=${device}` : device, + '-acodec', + 'libopus', + '-f', + 'opus', + '-ar', + '48000', + '-ac', + '2', + ], + }), + { + inputType: StreamType.OggOpus, + }, + ), + ); + console.log('Attached recorder - ready to go!'); +} + +player.on('stateChange', (oldState, newState) => { + if (oldState.status === AudioPlayerStatus.Idle && newState.status === AudioPlayerStatus.Playing) { + console.log('Playing audio output on audio player'); + } else if (newState.status === AudioPlayerStatus.Idle) { + console.log('Playback has stopped. Attempting to restart.'); + attachRecorder(); + } +}); + +/** + * + * @param {any} channel + * @returns {any} + */ +export async function connectToChannel(channel) { + const connection = joinVoiceChannel({ + channelId: channel.id, + guildId: channel.guild.id, + adapterCreator: channel.guild.voiceAdapterCreator, + }); + try { + await entersState(connection, VoiceConnectionStatus.Ready, 30_000); + return connection; + } catch (error) { + connection.destroy(); + throw error; + } +} + +export async function getVoiceChannelFromID(client, channelID) { + return client.channels.cache.get(channelID) +} + +export async function initDiscordBotClient(token, readyCallback){ + const client = new Client({ + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildVoiceStates, GatewayIntentBits.MessageContent], + }); + + client.on(Events.ClientReady, () => { + console.log('discord.js client is ready!'); + attachRecorder(); + readyCallback(client); + }); + + client.on(Events.MessageCreate, async (message) => { + if (!message.guild) return; + console.log(`New Message:`, message.content); + if (message.content === '-join') { + const channel = message.member?.voice.channel; + if (channel) { + try { + const connection = await connectToChannel(channel); + connection.subscribe(player); + await message.reply('Playing now!'); + } catch (error) { + console.error(error); + } + } else { + await message.reply('Join a voice channel then try again!'); + } + } + }); + + client.login(token); +} \ No newline at end of file diff --git a/client/modules/radioPresetHandler.js b/client/modules/radioPresetHandler.js new file mode 100644 index 0000000..cab19f1 --- /dev/null +++ b/client/modules/radioPresetHandler.js @@ -0,0 +1,142 @@ +// Debug +const { DebugBuilder } = require("../utilities/debugBuilder.js"); +const log = new DebugBuilder("client", "updatePresets"); +// Modules +const fs = require('fs'); +const path = require("path"); +const converter = require("convert-units"); + +/** + * Write the given presets to the JSON file + * @param presets The preset or presets to be written + * @param {function} callback The function to be called when this wrapper completes + */ +function writePresets(presets, callback = undefined) { + log.DEBUG(`${__dirname}`); + fs.writeFile("./config/radioPresets.json", JSON.stringify(presets), (err) => { + // Error checking + if (err) throw err; + log.DEBUG("Write Complete"); + if (callback) callback(); else return + }); +} + +/** + * Wrapper to ensure each value in the array is in Hz format + * @param frequenciesArray + * @returns {*[]} + */ +function sanitizeFrequencies(frequenciesArray) { + let sanitizedFrequencyArray = []; + + for (const freq of frequenciesArray) { + sanitizedFrequencyArray.push(convertFrequencyToHertz(freq)); + } + + log.DEBUG("Sanitized Frequency Array", sanitizedFrequencyArray); + return sanitizedFrequencyArray; +} + +/** + * Function to convert a string or a float into the integer type needed to be saved + * @param frequency Could be a string, number or float, + * @returns {number|number|*} Return the value to be saved in Hz format ("154.875"MHz format = "154875000") + */ +function convertFrequencyToHertz(frequency){ + // check if the passed value is a number + if(typeof frequency == 'number' && !isNaN(frequency)){ + if (Number.isInteger(frequency)) { + log.DEBUG(`${frequency} is an integer.`); + // Check to see if the frequency has the correct length + if (frequency >= 1000000) return frequency + if (frequency >= 100 && frequency <= 999) return frequency * 1000000 + log.WARN("Frequency hasn't matched filters: ", frequency); + } + else { + log.DEBUG(`${frequency} is a float value.`); + // Convert to a string to remove the decimal in place and then correct the length + return parseInt(converter(frequency).from("MHz").to("Hz")); + } + } else { + log.DEBUG(`${frequency} is not a number`); + frequency = convertFrequencyToHertz(parseFloat(frequency)); + + return parseInt(frequency) + } +} + +/** + * Gets the saved presets and returns a preset object + * @returns {any} The object containing the different systems the bot is near + */ +function getPresets() { + const presetDir = path.resolve("./config/radioPresets.json"); + log.DEBUG(`Getting presets from directory: '${presetDir}'`); + if (fs.existsSync(presetDir)) return JSON.parse(fs.readFileSync(presetDir)); + else return {}; +} + +/** + * Adds a new preset to the radioPresets JSON file + * + * @param {string} systemName The name of the system being added + * @param {Array} frequencies The frequency or frequencies the SDR should tune to for this system + * @param {string} mode The listening mode the SDR should be using when listening to this frequency + * @param {function} callback The callback function to call when completed + * @param {string} trunkFile The file that contains all trunking information (if applicable to the selected listening mode) + * @param {string} whitelistFile The file that contains the whitelisted talkgroups [optional] + */ +function addNewPreset(systemName, frequencies, mode, callback, trunkFile = undefined, whitelistFile = undefined) { + const presets = this.getPresets(); + // Create the preset for the new system + presets[systemName] = { + "frequencies": sanitizeFrequencies(frequencies), + "mode": mode, + "trunkFile": trunkFile ?? "none", + "whitelistFile": whitelistFile ?? "none" + } + // Write the changes to the preset config file + writePresets(presets, callback); +} + +/** + * Updates the specified system + * + * @param {string} systemName The name of the system being modified + * @param {function} callback The callback function to be called when the function completes + * @param {Array} frequencies The frequency or frequencies the SDR should tune to for this system + * @param {string} mode The listening mode the SDR should be using when listening to this frequency + * @param {string} trunkFile The file that contains all trunking information (if applicable to the selected listening mode) + * @param {string} whitelistFile The file that contains the whitelisted talkgroups [optional] + */ +function updatePreset(systemName, callback, { frequencies = undefined, mode = undefined, trunkFile = undefined, whitelistFile = undefined }) { + const presets = this.getPresets(); + // Check if a system name was passed + if (systemName in presets) { + // System name exists, checking to see if the keys are different + if(frequencies && sanitizeFrequencies(frequencies) !== presets[systemName].frequencies) presets[systemName].frequencies = sanitizeFrequencies(frequencies); + if(mode && mode !== presets[systemName].mode) presets[systemName].mode = mode; + if(trunkFile && trunkFile !== presets[systemName].trunkFile || trunkFile === "") presets[systemName].trunkFile = trunkFile ?? "none"; + if(whitelistFile && whitelistFile !== presets[systemName].whitelistFile || whitelistFile === "") presets[systemName].whitelistFile = whitelistFile ?? "none"; + // Write the changes + writePresets(presets, callback); + } +} + +/** + * Deletes the specified system + * + * @param {string} systemName The name of the system being modified + * @param {function} callback The callback function to be called when the function completes + */ +function removePreset(systemName, callback) { + const presets = this.getPresets(); + // Check if a system name was passed + if (systemName in presets) { + delete presets[systemName]; + writePresets(presets, callback); + } +} + + + diff --git a/client/modules/socketClient.mjs b/client/modules/socketClient.mjs index 06e5b19..51d8f42 100644 --- a/client/modules/socketClient.mjs +++ b/client/modules/socketClient.mjs @@ -1,6 +1,7 @@ import { io } from "socket.io-client"; +import { connectToChannel, initDiscordBotClient, getVoiceChannelFromID } from '../discordAudioBot/dab.mjs'; -export function initSocketConnection() { +export function initSocketConnection(localNodeConfig) { const serverEndpoint = `http://${localNodeConfig.serverIp}:${localNodeConfig.serverPort}` || 'http://localhost:3000'; // Adjust the server endpoint const socket = io.connect(serverEndpoint); @@ -8,14 +9,26 @@ export function initSocketConnection() { return socket; } -export function initSocketListeners(socket){ +export function initSocketListeners(socket, localNodeConfig) { socket.on('connect', () => { console.log('Connected to the server'); - logIntoServer(socket); + logIntoServer(socket, localNodeConfig.node); }); - socket.on('node-join', (joinData) => { + socket.on('node-join', async (joinData) => { console.log("Join requested: ", joinData) + // TODO - Implement logic to control OP25 for the requested channel + + // Join the requested channel with the requested ID + initDiscordBotClient(joinData.clientID, client => { + console.log("Client:", client); + getVoiceChannelFromID(client, joinData.channelID).then(vc => { + console.log("Voice Channel:", vc); + console.log("Voice Channel Guild:", vc.Guild); + const connection = connectToChannel(vc); + }) + }); + console.log("All done?"); }); socket.on('node-leave', () => { diff --git a/client/package.json b/client/package.json index 7a673a4..207687b 100644 --- a/client/package.json +++ b/client/package.json @@ -19,4 +19,7 @@ "replace-in-file": "^7.1.0", "socket.io-client": "^4.7.2" }, + "devDependencies": { + "typescript": "^5.3.3" + } } diff --git a/server/discordBot/modules/deployCommands.mjs b/server/discordBot/modules/deployCommands.mjs deleted file mode 100644 index 51bcd54..0000000 --- a/server/discordBot/modules/deployCommands.mjs +++ /dev/null @@ -1,80 +0,0 @@ -import { REST, Routes } from 'discord.js'; - -import dotenv from 'dotenv'; -dotenv.config() - -//const clientId = process.env.clientId; -//const guildId = process.env.guildId; - -const fs = require('node:fs'); -const path = require('node:path'); - -var commands = []; -// Grab all the command files from the commands directory you created earlier -const commandsPath = path.resolve(__dirname, '../commands'); -const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js')); - -export function deploy (clientId, guildIDs) { - console.log("Deploying commands for: ", guildIDs); - if (!Array.isArray(guildIDs)) guildIDs = [guildIDs]; - // Grab the SlashCommandBuilder#toJSON() output of each command's data for deployment - for (const file of commandFiles) { - const command = require(`${path.resolve(commandsPath, file)}`); - console.log('Deploying Command: ', command); - commands.push(command.data.toJSON()); - } - - // Construct and prepare an instance of the REST module - const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_TOKEN); - - // and deploy your commands! - for (const guildId of guildIDs) { - (async () => { - try { - console.log(`Started refreshing ${commands.length} application (/) commands for guild ID: ${guildId}.`); - // The put method is used to fully refresh all commands in the guild with the current set - const data = await rest.put( - Routes.applicationGuildCommands(clientId, guildId), - { body: commands }, - ); - - console.log(`Successfully reloaded ${data.length} application (/) commands for guild ID: ${guildId}.`); - } catch (error) { - // And of course, make sure you catch and log any errors! - console.log("ERROR Deploying commands: ", error, "Body from error: ", commands); - } - })() - } -}; - -/** - * Remove all commands for a given bot in a given guild - * - * @param {*} clientId The client ID of the bot to remove commands from - * @param {*} guildId The ID of the guild to remove the bot commands from - */ -export function removeAll (clientId, guildId) { - if (!Array.isArray(guildId)) guildIDs = [guildId]; - console.log("Removing commands for: ", clientId, guildIDs); - - commands = []; - - const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_TOKEN); - for (const guildId of guildIDs) { - (async () => { - try { - console.log(`Started refreshing ${commands.length} application (/) commands for guild ID: ${guildId}.`); - // The put method is used to fully refresh all commands in the guild with the current set - const data = await rest.put( - Routes.applicationGuildCommands(clientId, guildId), - { body: commands }, - ); - - console.log(`Successfully reloaded ${data.length} application (/) commands for guild ID: ${guildId}.`); - } catch (error) { - // And of course, make sure you catch and log any errors! - console.log("ERROR Deploying commands: ", error, "Body from error: ", commands); - } - })() - } -} \ No newline at end of file diff --git a/server/modules/discordBot.mjs b/server/modules/discordBot.mjs index 08e76a4..b9fd1c4 100644 --- a/server/modules/discordBot.mjs +++ b/server/modules/discordBot.mjs @@ -1,4 +1,6 @@ import { Client, GatewayIntentBits } from 'discord.js'; +//import { deployActiveCommands } from '../discordBot/modules/deployCommands.mjs' + import dotenv from 'dotenv'; dotenv.config() diff --git a/server/modules/socketServer.mjs b/server/modules/socketServer.mjs index 68b1dfc..964de0e 100644 --- a/server/modules/socketServer.mjs +++ b/server/modules/socketServer.mjs @@ -28,10 +28,16 @@ nodeIo.on('connection', (socket) => { console.log('user disconnected'); }); - // Test commands - setTimeout(() => { sendNodeCommand(socket, "node-join", { 'some': 'data' }); }, 2500) - setTimeout(() => { sendNodeCommand(socket, "node-leave", {}); }, 3500) + setTimeout(() => { + const joinData = { + 'clientID': "MTE5NjAwNTM2ODYzNjExMjk3Nw.GuCMXg.24iNNofNNumq46FIj68zMe9RmQgugAgfrvelEA", + 'channelID': "367396189529833476", + 'preset': "" + } + sendNodeCommand(socket, "node-join", joinData); + }, 2500) + //setTimeout(() => { sendNodeCommand(socket, "node-leave", {}); }, 3500) }); function sendNodeCommand(socket, command, data) { @@ -55,5 +61,4 @@ function updateNodeData(data) { function nodeLoginWrapper(data) { console.log(`Login requested from node: ${data.id}`, data); - } \ No newline at end of file