diff --git a/frontend/react/package.json b/frontend/react/package.json index ca6df087..a77bbafd 100644 --- a/frontend/react/package.json +++ b/frontend/react/package.json @@ -19,6 +19,7 @@ "@types/leaflet": "^1.9.8", "@types/react-leaflet": "^3.0.0", "@types/turf": "^3.5.32", + "buffer": "^6.0.3", "js-sha256": "^0.11.0", "leaflet": "^1.9.4", "leaflet-control-mini-map": "^0.4.0", @@ -32,6 +33,7 @@ }, "devDependencies": { "@eslint/js": "^9.6.0", + "@types/node": "^22.5.1", "@types/react": "^18.2.66", "@types/react-dom": "^18.2.22", "@typescript-eslint/parser": "^7.14.1", diff --git a/frontend/react/src/audio/audiomanager.ts b/frontend/react/src/audio/audiomanager.ts new file mode 100644 index 00000000..dc177c6e --- /dev/null +++ b/frontend/react/src/audio/audiomanager.ts @@ -0,0 +1,88 @@ +import { AudioRadioSetting } from "../interfaces"; +import { getApp } from "../olympusapp"; +import { Buffer } from "buffer"; +import { MicrophoneHandler } from "./microphonehandler"; + +enum MessageType { + audio, + settings, +} + +export class AudioManager { + #radioSettings: AudioRadioSetting[] = [ + { + frequency: 124000000, + modulation: 0, + ptt: false, + tuned: false, + volume: 0.5, + }, + ]; + + #microphoneHandlers: (MicrophoneHandler | null)[] =[]; + + #address: string = "localhost"; + #port: number = 4000; + #socket: WebSocket | null = null; + + constructor() { + document.addEventListener("configLoaded", () => { + let config = getApp().getConfig(); + if (config["WSPort"]) { + this.setPort(config["WSPort"]); + this.start(); + } + }); + + this.#microphoneHandlers = this.#radioSettings.map(() => null); + } + + start() { + let res = this.#address.match(/(?:http|https):\/\/(.+):/); + let wsAddress = res ? res[1] : this.#address; + + this.#socket = new WebSocket(`ws://${wsAddress}:${this.#port}`); + + this.#socket.addEventListener("open", (event) => { + console.log("Connection to audio websocket successfull"); + }); + + this.#socket.addEventListener("error", (event) => { + console.log(event); + }); + + this.#socket.addEventListener("message", (event) => { + console.log("Message from server ", event.data); + }); + } + + setAddress(address) { + this.#address = address; + } + + setPort(port) { + this.#port = port; + } + + getRadioSettings() { + return JSON.parse(JSON.stringify(this.#radioSettings)); + } + + setRadioSettings(radioSettings: AudioRadioSetting[]) { + this.#radioSettings = radioSettings; + + let message = { + type: "Settings update", + settings: this.#radioSettings, + }; + + this.#radioSettings.forEach((setting, idx) => { + if (setting.ptt && !this.#microphoneHandlers[idx]) { + this.#microphoneHandlers[idx] = new MicrophoneHandler(this.#socket, setting); + } + }) + + if (this.#socket?.readyState == 1) + this.#socket?.send(new Uint8Array([MessageType.settings, ...Buffer.from(JSON.stringify(message), "utf-8")])); + } +} diff --git a/frontend/react/src/audio/audiopacket.ts b/frontend/react/src/audio/audiopacket.ts new file mode 100644 index 00000000..f7700ee2 --- /dev/null +++ b/frontend/react/src/audio/audiopacket.ts @@ -0,0 +1,70 @@ +import { Buffer } from "buffer"; + +function getBytes(value, length) { + let res: number[] = []; + for (let i = 0; i < length; i++) { + res.push(value & 255); + value = value >> 8; + } + return res; +} + +function doubleToByteArray(number) { + var buffer = new ArrayBuffer(8); // JS numbers are 8 bytes long, or 64 bits + var longNum = new Float64Array(buffer); // so equivalent to Float64 + + longNum[0] = number; + + return Array.from(new Uint8Array(buffer)); +} + +var packetID = 0; + +export class AudioPacket { + #packet: Uint8Array; + + constructor(data, settings) { + let header: number[] = [0, 0, 0, 0, 0, 0]; + + let encFrequency: number[] = [...doubleToByteArray(settings.frequency)]; + let encModulation: number[] = [settings.modulation]; + let encEncryption: number[] = [0]; + + let encUnitID: number[] = getBytes(100000001, 4); + let encPacketID: number[] = getBytes(packetID, 8); + packetID++; + let encHops: number[] = [0]; + + let packet: number[] = ([] as number[]).concat( + header, + [...data], + encFrequency, + encModulation, + encEncryption, + encUnitID, + encPacketID, + encHops, + [...Buffer.from("ImF72dh9EYcIDyYRGaF9S9", "utf-8")], + [...Buffer.from("ImF72dh9EYcIDyYRGaF9S9", "utf-8")] + ); + + let encPacketLen = getBytes(packet.length, 2); + packet[0] = encPacketLen[0]; + packet[1] = encPacketLen[1]; + + let encAudioLen = getBytes(data.length, 2); + packet[2] = encAudioLen[0]; + packet[3] = encAudioLen[1]; + + let frequencyAudioLen = getBytes(10, 2); + packet[4] = frequencyAudioLen[0]; + packet[5] = frequencyAudioLen[1]; + + + this.#packet = new Uint8Array([0].concat(packet)); + } + + getArray() { + return this.#packet; + } +} diff --git a/frontend/react/src/audio/microphonehandler.ts b/frontend/react/src/audio/microphonehandler.ts new file mode 100644 index 00000000..c099e3ad --- /dev/null +++ b/frontend/react/src/audio/microphonehandler.ts @@ -0,0 +1,52 @@ +import { AudioRadioSetting } from "../interfaces"; +import { AudioPacket } from "./audiopacket"; + +export class MicrophoneHandler { + #socket: WebSocket; + #setting: AudioRadioSetting; + + constructor(socket, setting) { + this.#socket = socket; + this.#setting = setting; + + console.log("Starting microphone handler"); + + //@ts-ignore + let getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia; + + if (getUserMedia) { + //@ts-ignore + navigator.getUserMedia( + { audio: { + sampleRate: 16000, + channelCount: 1, + volume: 1.0 + } }, + (stream) => { + this.start_microphone(stream); + }, + (e) => { + alert("Error capturing audio."); + } + ); + } else { + alert("getUserMedia not supported in this browser."); + } + } + + start_microphone(stream) { + const recorder = new MediaRecorder(stream); + + // fires every one second and passes an BlobEvent + recorder.ondataavailable = async (event) => { + // get the Blob from the event + const blob = event.data; + + let rawData = await blob.arrayBuffer(); + let packet = new AudioPacket(new Uint8Array(rawData), this.#setting); + this.#socket.send(packet.getArray()); + }; + + recorder.start(200); + } +} diff --git a/frontend/react/src/eventscontext.tsx b/frontend/react/src/eventscontext.tsx index fdf399d8..ac44f7fc 100644 --- a/frontend/react/src/eventscontext.tsx +++ b/frontend/react/src/eventscontext.tsx @@ -8,6 +8,7 @@ export const EventsContext = createContext({ setDrawingMenuVisible: (e: boolean) => {}, setOptionsMenuVisible: (e: boolean) => {}, setAirbaseMenuVisible: (e: boolean) => {}, + setRadioMenuVisible: (e: boolean) => {}, toggleMainMenuVisible: () => {}, toggleSpawnMenuVisible: () => {}, toggleUnitControlMenuVisible: () => {}, @@ -15,6 +16,7 @@ export const EventsContext = createContext({ toggleDrawingMenuVisible: () => {}, toggleOptionsMenuVisible: () => {}, toggleAirbaseMenuVisible: () => {}, + toggleRadioMenuVisible: () => {}, }); export const EventsProvider = EventsContext.Provider; diff --git a/frontend/react/src/interfaces.ts b/frontend/react/src/interfaces.ts index 6c8c4904..cabd9fc5 100644 --- a/frontend/react/src/interfaces.ts +++ b/frontend/react/src/interfaces.ts @@ -291,3 +291,11 @@ export interface ServerStatus { connected: boolean; paused: boolean; } + +export interface AudioRadioSetting { + frequency: number; + modulation: number; + volume: number; + ptt: boolean; + tuned: boolean; +} \ No newline at end of file diff --git a/frontend/react/src/olympusapp.ts b/frontend/react/src/olympusapp.ts index 5e685cba..eaf90099 100644 --- a/frontend/react/src/olympusapp.ts +++ b/frontend/react/src/olympusapp.ts @@ -25,6 +25,7 @@ import { helicopterDatabase } from "./unit/databases/helicopterdatabase"; import { groundUnitDatabase } from "./unit/databases/groundunitdatabase"; import { navyUnitDatabase } from "./unit/databases/navyunitdatabase"; import { Coalition, Context } from "./types/types"; +import { AudioManager } from "./audio/audiomanager"; export var VERSION = "{{OLYMPUS_VERSION_NUMBER}}"; export var IP = window.location.toString(); @@ -45,6 +46,7 @@ export class OlympusApp { #shortcutManager: ShortcutManager | null = null; #unitsManager: UnitsManager | null = null; #weaponsManager: WeaponsManager | null = null; + #audioManager: AudioManager | null = null; //#pluginsManager: // TODO /* Current context */ @@ -79,6 +81,10 @@ export class OlympusApp { getMissionManager() { return this.#missionManager as MissionManager; } + + getAudioManager() { + return this.#audioManager as AudioManager; + } /* TODO getPluginsManager() { @@ -151,9 +157,11 @@ export class OlympusApp { this.#shortcutManager = new ShortcutManager(); this.#unitsManager = new UnitsManager(); this.#weaponsManager = new WeaponsManager(); + this.#audioManager = new AudioManager(); /* Set the address of the server */ this.getServerManager().setAddress(window.location.href.split("?")[0].replace("vite/", "")); + this.getAudioManager().setAddress(window.location.href.split("?")[0].replace("vite/", "")); /* Setup all global events */ this.#setupEvents(); diff --git a/frontend/react/src/statecontext.tsx b/frontend/react/src/statecontext.tsx index 5c2d9577..58cb62a3 100644 --- a/frontend/react/src/statecontext.tsx +++ b/frontend/react/src/statecontext.tsx @@ -9,6 +9,7 @@ export const StateContext = createContext({ drawingMenuVisible: false, optionsMenuVisible: false, airbaseMenuVisible: false, + radioMenuVisible: false, mapHiddenTypes: MAP_HIDDEN_TYPES_DEFAULTS, mapOptions: MAP_OPTIONS_DEFAULTS, mapSources: [] as string[], diff --git a/frontend/react/src/ui/components/olfrequencyinput.tsx b/frontend/react/src/ui/components/olfrequencyinput.tsx new file mode 100644 index 00000000..eaf4e9a3 --- /dev/null +++ b/frontend/react/src/ui/components/olfrequencyinput.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { ChangeEvent } from "react"; +import { OlNumberInput } from "./olnumberinput"; + +export function OlFrequencyInput(props: { value: number; className?: string; onChange: (value: number) => void }) { + let frequency = props.value; + + return ( +
+ { + let newValue = Math.max(Math.min(Number(e.target.value), 400), 1) * 1000000; + let decimalPart = frequency - Math.floor(frequency / 1000000) * 1000000; + frequency = newValue + decimalPart; + props.onChange(frequency); + }} + onDecrease={() => { + frequency = Math.max(Math.min(Number(frequency - 1000000), 400000000), 1000000); + props.onChange(frequency); + }} + onIncrease={() => { + frequency = Math.max(Math.min(Number(frequency + 1000000), 400000000), 1000000); + props.onChange(frequency); + }} + value={Math.floor(frequency / 1000000)} + > +
.
+ { + let newValue = Math.max(Math.min(Number(e.target.value), 990), 0) * 1000; + let integerPart = Math.floor(frequency / 1000000) * 1000000; + frequency = newValue + integerPart; + props.onChange(frequency); + }} + onDecrease={() => { + frequency = Math.max(Math.min(Number(frequency - 25000), 400000000), 1000000); + props.onChange(frequency); + }} + onIncrease={() => { + frequency = Math.max(Math.min(Number(frequency + 25000), 400000000), 1000000); + props.onChange(frequency); + }} + value={(frequency - Math.floor(frequency / 1000000) * 1000000) / 1000} + > +
MHz
+
+ ); +} diff --git a/frontend/react/src/ui/components/olrangeslider.tsx b/frontend/react/src/ui/components/olrangeslider.tsx index 59f80b95..9dc53c2c 100644 --- a/frontend/react/src/ui/components/olrangeslider.tsx +++ b/frontend/react/src/ui/components/olrangeslider.tsx @@ -5,6 +5,7 @@ export function OlRangeSlider(props: { min?: number; max?: number; step?: number; + className?: string; onChange: (e: ChangeEvent) => void; }) { var elementRef = useRef(null); @@ -28,6 +29,7 @@ export function OlRangeSlider(props: { max={props.max ?? 100} step={props.step ?? 1} className={` + ${props.className} h-2 w-full cursor-pointer appearance-none rounded-lg bg-gray-200 dark:bg-gray-700 `} diff --git a/frontend/react/src/ui/panels/options.tsx b/frontend/react/src/ui/panels/optionsmenu.tsx similarity index 98% rename from frontend/react/src/ui/panels/options.tsx rename to frontend/react/src/ui/panels/optionsmenu.tsx index d1ab34d0..650ab8fe 100644 --- a/frontend/react/src/ui/panels/options.tsx +++ b/frontend/react/src/ui/panels/optionsmenu.tsx @@ -6,7 +6,7 @@ import { OlNumberInput } from "../components/olnumberinput"; import { MapOptions } from "../../types/types"; import { getApp } from "../../olympusapp"; -export function Options(props: { open: boolean; onClose: () => void; options: MapOptions; children?: JSX.Element | JSX.Element[] }) { +export function OptionsMenu(props: { open: boolean; onClose: () => void; options: MapOptions; children?: JSX.Element | JSX.Element[] }) { return (
void; children?: JSX.Element | JSX.Element[] }) { + const [frequency1, setFrequency1] = useState(124000000); + const [ptt1, setPTT1] = useState(false); + const [frequency2, setFrequency2] = useState(251000000); + const [frequency3, setFrequency3] = useState(243000000); + const [frequency4, setFrequency4] = useState(11200000); + + useEffect(() => { + if (getApp()) { + let settings = getApp().getAudioManager().getRadioSettings(); + settings[0].frequency = frequency1; + settings[0].ptt = ptt1; + getApp().getAudioManager().setRadioSettings(settings); + } + }); + + return ( + +
+
+ Radio 1 + { + setFrequency1(value); + }} + /> +
+ + {}} className="my-auto" /> + 50 +
+
+ {}}> + { + setPTT1(!ptt1); + }} + tooltip="Talk on frequency" + > + {}} tooltip="Tune to radio"> +
+
+
+
+ ); +} diff --git a/frontend/react/src/ui/panels/sidebar.tsx b/frontend/react/src/ui/panels/sidebar.tsx index a4642cf7..1294481d 100644 --- a/frontend/react/src/ui/panels/sidebar.tsx +++ b/frontend/react/src/ui/panels/sidebar.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import { OlStateButton } from "../components/olstatebutton"; -import { faGamepad, faRuler, faPencil, faEllipsisV, faCog, faQuestionCircle, faPlusSquare, faMagnifyingGlass, faPlaneDeparture } from "@fortawesome/free-solid-svg-icons"; +import { faGamepad, faRuler, faPencil, faEllipsisV, faCog, faQuestionCircle, faPlusSquare, faMagnifyingGlass, faPlaneDeparture, faRadio } from "@fortawesome/free-solid-svg-icons"; import { EventsConsumer } from "../../eventscontext"; import { StateConsumer } from "../../statecontext"; import { IDLE } from "../../constants/constants"; @@ -58,6 +58,12 @@ export function SideBar() { icon={faPlaneDeparture} tooltip="Hide/show airbase menu" > +
diff --git a/frontend/react/src/ui/panels/unitcontrolmenu.tsx b/frontend/react/src/ui/panels/unitcontrolmenu.tsx index 395d06c6..07922762 100644 --- a/frontend/react/src/ui/panels/unitcontrolmenu.tsx +++ b/frontend/react/src/ui/panels/unitcontrolmenu.tsx @@ -48,6 +48,7 @@ import { FaRadio } from "react-icons/fa6"; import { OlNumberInput } from "../components/olnumberinput"; import { Radio, TACAN } from "../../interfaces"; import { OlStringInput } from "../components/olstringinput"; +import { OlFrequencyInput } from "../components/olfrequencyinput"; export function UnitControlMenu(props: { open: boolean; onClose: () => void }) { const [selectedUnits, setSelectedUnits] = useState([] as Unit[]); @@ -1112,71 +1113,12 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
Radio frequency
- { - let newValue = Math.max(Math.min(Number(e.target.value), 400), 1) * 1000000; - if (activeAdvancedSettings) { - let decimalPart = activeAdvancedSettings.radio.frequency - Math.floor(activeAdvancedSettings.radio.frequency / 1000000) * 1000000; - activeAdvancedSettings.radio.frequency = newValue + decimalPart; - } + { + if (activeAdvancedSettings) { + activeAdvancedSettings.radio.frequency = value; setActiveAdvancedSettings(JSON.parse(JSON.stringify(activeAdvancedSettings))); - }} - onDecrease={() => { - if (activeAdvancedSettings) - activeAdvancedSettings.radio.frequency = Math.max( - Math.min(Number(activeAdvancedSettings.radio.frequency - 1000000), 400000000), - 1000000 - ); - setActiveAdvancedSettings(JSON.parse(JSON.stringify(activeAdvancedSettings))); - }} - onIncrease={() => { - if (activeAdvancedSettings) - activeAdvancedSettings.radio.frequency = Math.max( - Math.min(Number(activeAdvancedSettings.radio.frequency + 1000000), 400000000), - 1000000 - ); - setActiveAdvancedSettings(JSON.parse(JSON.stringify(activeAdvancedSettings))); - }} - value={activeAdvancedSettings ? Math.floor(activeAdvancedSettings.radio.frequency / 1000000) : 124} - > -
.
- { - let newValue = Math.max(Math.min(Number(e.target.value), 990), 0) * 1000; - if (activeAdvancedSettings) { - let integerPart = Math.floor(activeAdvancedSettings.radio.frequency / 1000000) * 1000000; - activeAdvancedSettings.radio.frequency = newValue + integerPart; - } - setActiveAdvancedSettings(JSON.parse(JSON.stringify(activeAdvancedSettings))); - }} - onDecrease={() => { - if (activeAdvancedSettings) - activeAdvancedSettings.radio.frequency = Math.max( - Math.min(Number(activeAdvancedSettings.radio.frequency - 25000), 400000000), - 1000000 - ); - setActiveAdvancedSettings(JSON.parse(JSON.stringify(activeAdvancedSettings))); - }} - onIncrease={() => { - if (activeAdvancedSettings) - activeAdvancedSettings.radio.frequency = Math.max( - Math.min(Number(activeAdvancedSettings.radio.frequency + 25000), 400000000), - 1000000 - ); - setActiveAdvancedSettings(JSON.parse(JSON.stringify(activeAdvancedSettings))); - }} - value={ - activeAdvancedSettings - ? (activeAdvancedSettings.radio.frequency - Math.floor(activeAdvancedSettings.radio.frequency / 1000000) * 1000000) / 1000 - : 0 } - > -
MHz
+ }}/>
diff --git a/frontend/react/src/ui/ui.tsx b/frontend/react/src/ui/ui.tsx index df013bc2..a4ee5af7 100644 --- a/frontend/react/src/ui/ui.tsx +++ b/frontend/react/src/ui/ui.tsx @@ -9,7 +9,7 @@ import { SpawnMenu } from "./panels/spawnmenu"; import { UnitControlMenu } from "./panels/unitcontrolmenu"; import { MainMenu } from "./panels/mainmenu"; import { SideBar } from "./panels/sidebar"; -import { Options } from "./panels/options"; +import { OptionsMenu } from "./panels/optionsmenu"; import { MapHiddenTypes, MapOptions } from "../types/types"; import { BLUE_COMMANDER, CONTEXT_ACTION, GAME_MASTER, IDLE, MAP_HIDDEN_TYPES_DEFAULTS, MAP_OPTIONS_DEFAULTS, RED_COMMANDER } from "../constants/constants"; import { getApp, setupApp } from "../olympusapp"; @@ -22,6 +22,7 @@ import { ControlsPanel } from "./panels/controlspanel"; import { MapContextMenu } from "./contextmenus/mapcontextmenu"; import { AirbaseMenu } from "./panels/airbasemenu"; import { Airbase } from "../mission/airbase"; +import { RadioMenu } from "./panels/radiomenu"; export type OlympusUIState = { mainMenuVisible: boolean; @@ -42,6 +43,7 @@ export function UI() { const [unitControlMenuVisible, setUnitControlMenuVisible] = useState(false); const [measureMenuVisible, setMeasureMenuVisible] = useState(false); const [drawingMenuVisible, setDrawingMenuVisible] = useState(false); + const [radioMenuVisible, setRadioMenuVisible] = useState(false); const [optionsMenuVisible, setOptionsMenuVisible] = useState(false); const [airbaseMenuVisible, setAirbaseMenuVisible] = useState(false); const [mapHiddenTypes, setMapHiddenTypes] = useState(MAP_HIDDEN_TYPES_DEFAULTS); @@ -97,6 +99,7 @@ export function UI() { setDrawingMenuVisible(false); setOptionsMenuVisible(false); setAirbaseMenuVisible(false); + setRadioMenuVisible(false); } function checkPassword(password: string) { @@ -153,6 +156,7 @@ export function UI() { drawingMenuVisible: drawingMenuVisible, optionsMenuVisible: optionsMenuVisible, airbaseMenuVisible: airbaseMenuVisible, + radioMenuVisible: radioMenuVisible, mapOptions: mapOptions, mapHiddenTypes: mapHiddenTypes, mapSources: mapSources, @@ -169,6 +173,7 @@ export function UI() { setMeasureMenuVisible: setMeasureMenuVisible, setOptionsMenuVisible: setOptionsMenuVisible, setAirbaseMenuVisible: setAirbaseMenuVisible, + setRadioMenuVisible: setRadioMenuVisible, toggleMainMenuVisible: () => { hideAllMenus(); setMainMenuVisible(!mainMenuVisible); @@ -197,6 +202,10 @@ export function UI() { hideAllMenus(); setAirbaseMenuVisible(!airbaseMenuVisible); }, + toggleRadioMenuVisible: () => { + hideAllMenus(); + setRadioMenuVisible(!radioMenuVisible); + }, }} >
@@ -227,10 +236,11 @@ export function UI() {
setMainMenuVisible(false)} /> setSpawnMenuVisible(false)} /> - setOptionsMenuVisible(false)} options={mapOptions} /> + setOptionsMenuVisible(false)} options={mapOptions} /> setUnitControlMenuVisible(false)} /> setDrawingMenuVisible(false)} /> setAirbaseMenuVisible(false)} airbase={airbase}/> + setRadioMenuVisible(false)} /> diff --git a/frontend/server/Example.ogg b/frontend/server/Example.ogg new file mode 100644 index 00000000..39f957bf Binary files /dev/null and b/frontend/server/Example.ogg differ diff --git a/frontend/server/package.json b/frontend/server/package.json index 2bda645b..5673d6cb 100644 --- a/frontend/server/package.json +++ b/frontend/server/package.json @@ -10,6 +10,7 @@ }, "private": true, "dependencies": { + "@discordjs/opus": "^0.9.0", "appjs": "^0.0.20", "appjs-win32": "^0.0.19", "body-parser": "^1.20.2", @@ -21,6 +22,7 @@ "express-basic-auth": "^1.2.1", "http-proxy-middleware": "^2.0.6", "morgan": "~1.9.1", + "node-opus": "^0.3.3", "open": "^10.0.0", "regedit": "^5.1.2", "save": "^2.9.0", @@ -28,6 +30,7 @@ "srtm-elevation": "^2.1.2", "tcp-ping-port": "^1.0.1", "uuid": "^9.0.1", + "wavefile": "^11.0.0", "ws": "^8.18.0", "yargs": "^17.7.2" }, diff --git a/frontend/server/sample1.WAV b/frontend/server/sample1.WAV new file mode 100644 index 00000000..941c804f Binary files /dev/null and b/frontend/server/sample1.WAV differ diff --git a/frontend/server/sample3.WAV b/frontend/server/sample3.WAV new file mode 100644 index 00000000..084bad48 Binary files /dev/null and b/frontend/server/sample3.WAV differ diff --git a/frontend/server/src/app.ts b/frontend/server/src/app.ts index e2534d67..59384e48 100644 --- a/frontend/server/src/app.ts +++ b/frontend/server/src/app.ts @@ -5,6 +5,7 @@ import logger = require("morgan"); import fs = require("fs"); import bodyParser = require("body-parser"); import cors = require("cors"); +import { AudioBackend } from "./audio/audiobackend"; /* Load the proxy middleware plugin */ import httpProxyMiddleware = require("http-proxy-middleware"); @@ -83,5 +84,10 @@ module.exports = function (configLocation, viteProxy) { }); } + if (config["audio"]) { + let audioBackend = new AudioBackend(config["audio"]["SRSPort"], config["audio"]["WSPort"]); + audioBackend.start(); + } + return app; }; diff --git a/frontend/server/src/audio/audiobackend.ts b/frontend/server/src/audio/audiobackend.ts new file mode 100644 index 00000000..fc20acc3 --- /dev/null +++ b/frontend/server/src/audio/audiobackend.ts @@ -0,0 +1,27 @@ +import { WebSocketServer } from "ws"; +import { SRSHandler } from "./srshandler"; + +export class AudioBackend { + SRSPort: number = 5002; + WSPort: number = 4000; + handlers: SRSHandler[] = []; + + constructor(SRSPort, WSPort) { + this.SRSPort = SRSPort ?? this.SRSPort; + this.WSPort = WSPort ?? this.WSPort; + } + + start() { + const wss = new WebSocketServer({ port: this.WSPort }); + + wss.on("connection", (ws) => { + this.handlers.push(new SRSHandler(ws, this.SRSPort)); + }); + + wss.on("disconnection", (ws) => { + this.handlers = this.handlers.filter((handler) => { + handler.ws != ws; + }); + }); + } +} diff --git a/frontend/server/src/audio/defaultdata.ts b/frontend/server/src/audio/defaultdata.ts new file mode 100644 index 00000000..962f6c49 --- /dev/null +++ b/frontend/server/src/audio/defaultdata.ts @@ -0,0 +1,112 @@ +export var defaultSRSData = { + ClientGuid: "", + Name: "", + Seat: 0, + Coalition: 0, + AllowRecord: false, + RadioInfo: { + radios: [ + { + enc: false, + encKey: 0, + freq: 1.0, + modulation: 3, + secFreq: 1.0, + retransmit: false, + }, + { + enc: false, + encKey: 0, + freq: 1.0, + modulation: 3, + secFreq: 1.0, + retransmit: false, + }, + { + enc: false, + encKey: 0, + freq: 1.0, + modulation: 3, + secFreq: 1.0, + retransmit: false, + }, + { + enc: false, + encKey: 0, + freq: 1.0, + modulation: 3, + secFreq: 1.0, + retransmit: false, + }, + { + enc: false, + encKey: 0, + freq: 1.0, + modulation: 3, + secFreq: 1.0, + retransmit: false, + }, + { + enc: false, + encKey: 0, + freq: 1.0, + modulation: 3, + secFreq: 1.0, + retransmit: false, + }, + { + enc: false, + encKey: 0, + freq: 1.0, + modulation: 3, + secFreq: 1.0, + retransmit: false, + }, + { + enc: false, + encKey: 0, + freq: 1.0, + modulation: 3, + secFreq: 1.0, + retransmit: false, + }, + { + enc: false, + encKey: 0, + freq: 1.0, + modulation: 3, + secFreq: 1.0, + retransmit: false, + }, + { + enc: false, + encKey: 0, + freq: 1.0, + modulation: 3, + secFreq: 1.0, + retransmit: false, + }, + { + enc: false, + encKey: 0, + freq: 1.0, + modulation: 3, + secFreq: 1.0, + retransmit: false, + }, + ], + unit: "", + unitId: 0, + iff: { + control: 2, + mode1: -1, + mode2: -1, + mode3: -1, + mode4: false, + mic: -1, + status: 0, + }, + ambient: { vol: 1.0, abType: "" }, + }, + LatLngPosition: { lat: 0.0, lng: 0.0, alt: 0.0 }, + }; \ No newline at end of file diff --git a/frontend/server/src/audio/srshandler.ts b/frontend/server/src/audio/srshandler.ts new file mode 100644 index 00000000..a813408e --- /dev/null +++ b/frontend/server/src/audio/srshandler.ts @@ -0,0 +1,84 @@ +import { defaultSRSData } from "./defaultdata"; +var net = require("net"); + +const SRS_VERSION = "2.1.0.10"; + +var globalIndex = 1; + +enum MessageType { + audio, + settings, +} + +function makeID(length) { + let result = ""; + const characters = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const charactersLength = characters.length; + let counter = 0; + while (counter < length) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + counter += 1; + } + return result; +} + +export class SRSHandler { + ws: any; + tcp = new net.Socket(); + udp = require("dgram").createSocket("udp4"); + data = JSON.parse(JSON.stringify(defaultSRSData)); + syncInterval: any; + + constructor(ws, SRSPort) { + this.data.ClientGuid = "ImF72dh9EYcIDyYRGaF9S9"; + this.data.Name = `Olympus${globalIndex}`; + globalIndex += 1; + + /* Websocket */ + this.ws = ws; + this.ws.on("error", console.error); + this.ws.on("message", (data) => { + switch (data[0]) { + case MessageType.audio: + this.udp.send(data.slice(1), 5002, "localhost", function (error) { + if (error) { + console.log("Error!!!"); + } else { + console.log("Data sent"); + } + }); + break; + case MessageType.settings: + let message = JSON.parse(data.slice(1)); + message.settings.forEach((setting, idx) => { + this.data.RadioInfo.radios[idx].freq = setting.frequency; + this.data.RadioInfo.radios[idx].modulation = setting.modulation; + }) + break; + default: + break; + } + }); + this.ws.on("close", () => { + this.tcp.end(); + }); + + /* TCP */ + this.tcp.connect(SRSPort, "localhost", () => { + console.log("Connected"); + + this.syncInterval = setInterval(() => { + let SYNC = { + Client: this.data, + MsgType: 2, + Version: SRS_VERSION, + }; + + if (this.tcp.readyState == "open") + this.tcp.write(`${JSON.stringify(SYNC)}\n`); + else clearInterval(this.syncInterval); + }, 1000); + }); + } +} diff --git a/frontend/server/src/routes/resources.ts b/frontend/server/src/routes/resources.ts index d2d12506..728c2fc7 100644 --- a/frontend/server/src/routes/resources.ts +++ b/frontend/server/src/routes/resources.ts @@ -7,7 +7,7 @@ module.exports = function (configLocation) { if (fs.existsSync(configLocation)) { let rawdata = fs.readFileSync(configLocation, "utf-8"); const config = JSON.parse(rawdata); - res.send(JSON.stringify(config.frontend)); + res.send(JSON.stringify({...config.frontend, ...(config.audio ?? {}) })); res.end() } else { res.sendStatus(404); diff --git a/frontend/server/srs.js b/frontend/server/srs.js new file mode 100644 index 00000000..ef9e355e --- /dev/null +++ b/frontend/server/srs.js @@ -0,0 +1,219 @@ +const WaveFile = require('wavefile').WaveFile; + +var fs = require('fs'); +let source = fs.readFileSync('sample3.WAV'); +let wav = new WaveFile(source); +let wavBuffer = wav.toBuffer(); +const { OpusEncoder } = require('@discordjs/opus'); +const encoder = new OpusEncoder(16000, 1); + +let fileIndex = 0; +let packetID = 0; + +var udp = require("dgram"); +var udpClient = udp.createSocket("udp4"); + +let clientData = { + ClientGuid: "AZi9CkptY0yW_C-3YmI7oQ", + Name: "Olympus", + Seat: 0, + Coalition: 0, + AllowRecord: false, + RadioInfo: { + radios: [ + { + enc: false, + encKey: 0, + freq: 1.0, + modulation: 3, + secFreq: 1.0, + retransmit: false, + }, + { + enc: false, + encKey: 0, + freq: 1.0, + modulation: 3, + secFreq: 1.0, + retransmit: false, + }, + { + enc: false, + encKey: 0, + freq: 1.0, + modulation: 3, + secFreq: 1.0, + retransmit: false, + }, + { + enc: false, + encKey: 0, + freq: 1.0, + modulation: 3, + secFreq: 1.0, + retransmit: false, + }, + { + enc: false, + encKey: 0, + freq: 1.0, + modulation: 3, + secFreq: 1.0, + retransmit: false, + }, + { + enc: false, + encKey: 0, + freq: 1.0, + modulation: 3, + secFreq: 1.0, + retransmit: false, + }, + { + enc: false, + encKey: 0, + freq: 1.0, + modulation: 3, + secFreq: 1.0, + retransmit: false, + }, + { + enc: false, + encKey: 0, + freq: 1.0, + modulation: 3, + secFreq: 1.0, + retransmit: false, + }, + { + enc: false, + encKey: 0, + freq: 1.0, + modulation: 3, + secFreq: 1.0, + retransmit: false, + }, + { + enc: false, + encKey: 0, + freq: 1.0, + modulation: 3, + secFreq: 1.0, + retransmit: false, + }, + { + enc: false, + encKey: 0, + freq: 1.0, + modulation: 3, + secFreq: 1.0, + retransmit: false, + }, + ], + unit: "", + unitId: 0, + iff: { + control: 2, + mode1: -1, + mode2: -1, + mode3: -1, + mode4: false, + mic: -1, + status: 0, + }, + ambient: { vol: 1.0, abType: "" }, + }, + LatLngPosition: { lat: 0.0, lng: 0.0, alt: 0.0 }, +}; + +var net = require("net"); + +var tcpClient = new net.Socket(); + +tcpClient.on("data", function (data) { + console.log("Received: " + data); + +}); + +tcpClient.on("close", function () { + console.log("Connection closed"); +}); + +tcpClient.connect(5002, "localhost", function () { + console.log("Connected"); + + setTimeout(() => { + let SYNC = { + Client: clientData, + MsgType: 2, + Version: "2.1.0.10", + }; + let string = JSON.stringify(SYNC); + tcpClient.write(string + "\n"); + + setInterval(() => { + let slice = []; + for (let i = 0; i < 16000 * 0.04; i++) { + slice.push(wavBuffer[Math.round(fileIndex) * 2], wavBuffer[Math.round(fileIndex) * 2 + 1]); + fileIndex += 44100 / 16000; + } + const encoded = encoder.encode(new Uint8Array(slice)); + + let header = [ + 0, 0, + 0, 0, + 0, 0 + ] + + let encFrequency = [...doubleToByteArray(251000000)]; + let encModulation = [2]; + let encEncryption = [0]; + + let encUnitID = getBytes(100000001, 4); + let encPacketID = getBytes(packetID, 8); + packetID++; + let encHops = [0]; + + let packet = [].concat(header, [...encoded], encFrequency, encModulation, encEncryption, encUnitID, encPacketID, encHops, [...Buffer.from(clientData.ClientGuid, 'utf-8')], [...Buffer.from(clientData.ClientGuid, 'utf-8')]); + + let encPacketLen = getBytes(packet.length, 2); + packet[0] = encPacketLen[0]; + packet[1] = encPacketLen[1]; + + let encAudioLen = getBytes(encoded.length, 2); + packet[2] = encAudioLen[0]; + packet[3] = encAudioLen[1]; + + let frequencyAudioLen = getBytes(10, 2); + packet[4] = frequencyAudioLen[0]; + packet[5] = frequencyAudioLen[1]; + + let data = new Uint8Array(packet); + udpClient.send(data, 5002, "localhost", function (error) { + if (error) { + tcpClient.close(); + } else { + console.log("Data sent !!!"); + } + }); + }, 40); + }, 1000); +}); + +function getBytes(value, length) { + let res = []; + for (let i = 0; i < length; i++) { + res.push(value & 255); + value = value >> 8; + } + return res; +} + +function doubleToByteArray(number) { + var buffer = new ArrayBuffer(8); // JS numbers are 8 bytes long, or 64 bits + var longNum = new Float64Array(buffer); // so equivalent to Float64 + + longNum[0] = number; + + return Array.from(new Uint8Array(buffer)); +} \ No newline at end of file diff --git a/olympus.json b/olympus.json index 383039c6..602660ac 100644 --- a/olympus.json +++ b/olympus.json @@ -1,7 +1,7 @@ { "backend": { "address": "localhost", - "port": 4512 + "port": 3001 }, "authentication": { "gameMasterPassword": "4b8823ed9e5c2392ab4a791913bb8ce41956ea32e308b760eefb97536746dd33", @@ -33,5 +33,9 @@ "DCS Map (Official)": "https://maps.dcsolympus.com/maps", "DCS Map (Alt.)": "https://refugees.dcsolympus.com/maps" } + }, + "audio": { + "SRSPort": 5002, + "WSPort": 4000 } }