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 (