diff --git a/frontend/react/src/audio/audiomanager.ts b/frontend/react/src/audio/audiomanager.ts
index 9bc8b25a..d0ecff6b 100644
--- a/frontend/react/src/audio/audiomanager.ts
+++ b/frontend/react/src/audio/audiomanager.ts
@@ -1,4 +1,4 @@
-import { AudioMessageType } from "../constants/constants";
+import { AudioMessageType, OlympusState } from "../constants/constants";
import { MicrophoneSource } from "./microphonesource";
import { RadioSink } from "./radiosink";
import { getApp } from "../olympusapp";
@@ -23,13 +23,14 @@ import {
} from "../events";
import { OlympusConfig } from "../interfaces";
import { TextToSpeechSource } from "./texttospeechsource";
+import { SpeechController } from "./speechcontroller";
export class AudioManager {
#audioContext: AudioContext;
- #synth = window.speechSynthesis;
#devices: MediaDeviceInfo[] = [];
#input: MediaDeviceInfo;
#output: MediaDeviceInfo;
+ #speechController: SpeechController;
/* The playback pipeline enables audio playback on the speakers/headphones */
#playbackPipeline: PlaybackPipeline;
@@ -50,6 +51,7 @@ export class AudioManager {
#guid: string = makeID(22);
#SRSClientUnitIDs: number[] = [];
#syncInterval: number;
+ #speechRecognition: boolean = true;
constructor() {
ConfigLoadedEvent.on((config: OlympusConfig) => {
@@ -70,6 +72,8 @@ export class AudioManager {
altKey: false,
});
});
+
+ this.#speechController = new SpeechController();
}
start() {
@@ -77,7 +81,6 @@ export class AudioManager {
this.#syncRadioSettings();
}, 1000);
- this.#running = true;
this.#audioContext = new AudioContext({ sampleRate: 16000 });
//@ts-ignore
@@ -124,7 +127,12 @@ export class AudioManager {
audioPacket.getFrequencies().forEach((frequencyInfo) => {
if (sink.getFrequency() === frequencyInfo.frequency && sink.getModulation() === frequencyInfo.modulation && sink.getTuned()) {
sink.setReceiving(true);
- this.#playbackPipeline.playBuffer(audioPacket.getAudioData().buffer);
+
+ /* Make a copy of the array buffer for the playback pipeline to use */
+ var dst = new ArrayBuffer(audioPacket.getAudioData().buffer.byteLength);
+ new Uint8Array(dst).set(new Uint8Array(audioPacket.getAudioData().buffer));
+ sink.recordArrayBuffer(audioPacket.getAudioData().buffer);
+ this.#playbackPipeline.playBuffer(dst);
}
});
} else {
@@ -144,16 +152,40 @@ export class AudioManager {
this.#sources.push(microphoneSource);
AudioSourcesChangedEvent.dispatch(getApp().getAudioManager().getSources());
- /* Add two default radios */
- this.addRadio();
- this.addRadio();
+ let sessionRadios = getApp().getSessionDataManager().getSessionData().radios;
+ if (sessionRadios) {
+ /* Load session radios */
+ sessionRadios.forEach((options) => {
+ let newRadio = this.addRadio();
+ newRadio?.setFrequency(options.frequency);
+ newRadio?.setModulation(options.modulation);
+ });
+ } else {
+ /* Add two default radios */
+ this.addRadio();
+ this.addRadio();
+ }
+
+ let sessionUnitSinks = getApp().getSessionDataManager().getSessionData().unitSinks;
+ if (sessionUnitSinks) {
+ /* Load session radios */
+ sessionUnitSinks.forEach((options) => {
+ let unit = getApp().getUnitsManager().getUnitByID(options.ID);
+ if (unit) {
+ let newSink = this.addUnitSink(unit);
+ }
+ });
+ }
+ let sessionFileSources = getApp().getSessionDataManager().getSessionData().fileSources;
+ if (sessionFileSources && sessionFileSources.length > 0) getApp().setState(OlympusState.LOAD_FILES);
+
+ this.#running = true;
+ AudioManagerStateChangedEvent.dispatch(this.#running);
});
const textToSpeechSource = new TextToSpeechSource();
this.#sources.push(textToSpeechSource);
- AudioManagerStateChangedEvent.dispatch(this.#running);
-
navigator.mediaDevices.enumerateDevices().then((devices) => {
this.#devices = devices;
AudioManagerDevicesChangedEvent.dispatch(devices);
@@ -190,13 +222,10 @@ export class AudioManager {
addFileSource(file) {
console.log(`Adding file source from ${file.name}`);
- if (!this.#running) {
- console.log("Audio manager not started, aborting...");
- return;
- }
const newSource = new FileSource(file);
this.#sources.push(newSource);
AudioSourcesChangedEvent.dispatch(getApp().getAudioManager().getSources());
+ return newSource;
}
getSources() {
@@ -205,10 +234,6 @@ export class AudioManager {
removeSource(source: AudioSource) {
console.log(`Removing source ${source.getName()}`);
- if (!this.#running) {
- console.log("Audio manager not started, aborting...");
- return;
- }
source.disconnect();
this.#sources = this.#sources.filter((v) => v != source);
AudioSourcesChangedEvent.dispatch(this.#sources);
@@ -216,26 +241,22 @@ export class AudioManager {
addUnitSink(unit: Unit) {
console.log(`Adding unit sink for unit with ID ${unit.ID}`);
- if (!this.#running) {
- console.log("Audio manager not started, aborting...");
- return;
- }
- this.#sinks.push(new UnitSink(unit));
+ const newSink = new UnitSink(unit);
+ this.#sinks.push(newSink);
AudioSinksChangedEvent.dispatch(this.#sinks);
+ return newSink;
}
addRadio() {
console.log("Adding new radio");
- if (!this.#running || this.#sources[0] === undefined) {
- console.log("Audio manager not started, aborting...");
- return;
- }
const newRadio = new RadioSink();
+ newRadio.speechDataAvailable = (blob) => this.#speechController.analyzeData(blob);
this.#sinks.push(newRadio);
/* Set radio name by default to be incremental number */
newRadio.setName(`Radio ${this.#sinks.length}`);
this.#sources.find((source) => source instanceof MicrophoneSource)?.connect(newRadio);
AudioSinksChangedEvent.dispatch(this.#sinks);
+ return newRadio;
}
getSinks() {
@@ -244,10 +265,6 @@ export class AudioManager {
removeSink(sink) {
console.log(`Removing sink ${sink.getName()}`);
- if (!this.#running) {
- console.log("Audio manager not started, aborting...");
- return;
- }
sink.disconnect();
this.#sinks = this.#sinks.filter((v) => v != sink);
let idx = 1;
@@ -309,12 +326,20 @@ export class AudioManager {
this.#sources.find((source) => source instanceof TextToSpeechSource)?.playText(text);
}
+ setSpeechRecognition(speechRecognition: boolean) {
+ this.#speechRecognition = this.#speechRecognition;
+ }
+
+ getSpeechRecognition() {
+ return this.#speechRecognition;
+ }
+
#syncRadioSettings() {
/* Send the radio settings of each radio to the SRS backend */
let message = {
type: "Settings update",
guid: this.#guid,
- coalition: 2,
+ coalition: 2, // TODO
settings: this.#sinks
.filter((sink) => sink instanceof RadioSink)
.map((radio) => {
diff --git a/frontend/react/src/audio/filesource.ts b/frontend/react/src/audio/filesource.ts
index 29647c6b..229c699c 100644
--- a/frontend/react/src/audio/filesource.ts
+++ b/frontend/react/src/audio/filesource.ts
@@ -4,6 +4,7 @@ import { AudioSourcesChangedEvent } from "../events";
export class FileSource extends AudioSource {
#file: File;
+ #filename: string;
#source: AudioBufferSourceNode;
#duration: number = 0;
#currentPosition: number = 0;
@@ -17,6 +18,7 @@ export class FileSource extends AudioSource {
constructor(file) {
super();
this.#file = file;
+ this.#filename = this.#file?.name;
this.setName(this.#file?.name ?? "N/A");
@@ -117,4 +119,8 @@ export class FileSource extends AudioSource {
getLooping() {
return this.#looping;
}
+
+ getFilename() {
+ return this.#filename;
+ }
}
diff --git a/frontend/react/src/audio/radiosink.ts b/frontend/react/src/audio/radiosink.ts
index cf75253d..02bdc8eb 100644
--- a/frontend/react/src/audio/radiosink.ts
+++ b/frontend/react/src/audio/radiosink.ts
@@ -2,8 +2,8 @@ import { AudioSink } from "./audiosink";
import { AudioPacket } from "./audiopacket";
import { getApp } from "../olympusapp";
import { AudioSinksChangedEvent } from "../events";
-import { timeStamp } from "console";
import { makeID } from "../other/utils";
+import { Recorder } from "./recorder";
/* Radio sink, basically implements a simple SRS Client in Olympus. Does not support encryption at this moment */
export class RadioSink extends AudioSink {
@@ -19,10 +19,15 @@ export class RadioSink extends AudioSink {
#clearReceivingTimeout: number;
#packetID = 0;
#guid = makeID(22);
+ #recorder: Recorder;
+ speechDataAvailable: (blob: Blob) => void;
constructor() {
super();
+ this.#recorder = new Recorder();
+ this.#recorder.onRecordingCompleted = (blob) => this.speechDataAvailable(blob);
+
this.#encoder = new AudioEncoder({
output: (data) => this.handleEncodedData(data),
error: (e) => {
@@ -104,11 +109,19 @@ export class RadioSink extends AudioSink {
setReceiving(receiving) {
// Only do it if actually changed
- if (receiving !== this.#receiving) AudioSinksChangedEvent.dispatch(getApp().getAudioManager().getSinks());
+ if (receiving !== this.#receiving) {
+ AudioSinksChangedEvent.dispatch(getApp().getAudioManager().getSinks());
+
+ if (getApp().getAudioManager().getSpeechRecognition()) {
+ if (receiving) this.#recorder.start();
+ else this.#recorder.stop();
+ }
+ }
if (receiving) {
window.clearTimeout(this.#clearReceivingTimeout);
this.#clearReceivingTimeout = window.setTimeout(() => this.setReceiving(false), 500);
}
+
this.#receiving = receiving;
}
@@ -124,11 +137,13 @@ export class RadioSink extends AudioSink {
let audioPacket = new AudioPacket();
audioPacket.setAudioData(new Uint8Array(arrayBuffer));
audioPacket.setPacketID(this.#packetID++);
- audioPacket.setFrequencies([{
+ audioPacket.setFrequencies([
+ {
frequency: this.#frequency,
modulation: this.#modulation,
- encryption: 0
- }])
+ encryption: 0,
+ },
+ ]);
audioPacket.setClientGUID(getApp().getAudioManager().getGuid());
audioPacket.setTransmissionGUID(this.#guid);
getApp().getAudioManager().send(audioPacket.toByteArray());
@@ -139,4 +154,8 @@ export class RadioSink extends AudioSink {
this.#encoder.encode(audioData);
audioData.close();
}
+
+ recordArrayBuffer(arrayBuffer: ArrayBuffer) {
+ this.#recorder.recordBuffer(arrayBuffer);
+ }
}
diff --git a/frontend/react/src/audio/recorder.ts b/frontend/react/src/audio/recorder.ts
new file mode 100644
index 00000000..6b30d008
--- /dev/null
+++ b/frontend/react/src/audio/recorder.ts
@@ -0,0 +1,77 @@
+export class Recorder {
+ #decoder = new AudioDecoder({
+ output: (chunk) => this.#handleDecodedData(chunk),
+ error: (e) => console.log(e),
+ });
+ #trackGenerator: any; // TODO can we have typings?
+ #writer: any;
+ #gainNode: GainNode;
+ #mediaRecorder: MediaRecorder;
+ #recording = false;
+ #chunks: any[] = [];
+ onRecordingCompleted: (blob: Blob) => void
+
+ constructor() {
+ this.#decoder.configure({
+ codec: "opus",
+ numberOfChannels: 1,
+ sampleRate: 16000,
+ //@ts-ignore // TODO why is this giving an error?
+ opus: {
+ frameDuration: 40000,
+ },
+ bitrateMode: "constant",
+ });
+
+ //@ts-ignore
+ this.#trackGenerator = new MediaStreamTrackGenerator({ kind: "audio" });
+ this.#writer = this.#trackGenerator.writable.getWriter();
+
+ const stream = new MediaStream([this.#trackGenerator]);
+ this.#mediaRecorder = new MediaRecorder(stream, {
+ audioBitsPerSecond: 256000,
+ mimeType: `audio/webm;codecs="opus"`,
+ });
+ }
+
+ recordBuffer(arrayBuffer) {
+ if (!this.#recording) return;
+
+ const init = {
+ type: "key",
+ data: arrayBuffer,
+ timestamp: 0,
+ duration: 2000000,
+ transfer: [arrayBuffer],
+ };
+ //@ts-ignore //TODO Typings?
+ let encodedAudioChunk = new EncodedAudioChunk(init);
+
+ this.#decoder.decode(encodedAudioChunk);
+ }
+
+ start() {
+ this.#mediaRecorder.start();
+ this.#recording = true;
+ this.#chunks = [];
+
+ this.#mediaRecorder.onstop = (e) => {
+ if (this.#chunks.length > 0) this.onRecordingCompleted(this.#chunks[0]);
+ };
+
+ this.#mediaRecorder.ondataavailable = (e) => {
+ this.#chunks.push(e.data);
+ };
+ }
+
+ stop() {
+ this.#mediaRecorder.stop();
+ this.#recording = false;
+ }
+
+ #handleDecodedData(audioData) {
+ this.#writer.ready.then(() => {
+ this.#writer.write(audioData);
+ });
+ }
+}
diff --git a/frontend/react/src/audio/speechcontroller.ts b/frontend/react/src/audio/speechcontroller.ts
new file mode 100644
index 00000000..da2a9d84
--- /dev/null
+++ b/frontend/react/src/audio/speechcontroller.ts
@@ -0,0 +1,45 @@
+import { getApp } from "../olympusapp";
+import { blobToBase64 } from "../other/utils";
+
+export class SpeechController {
+ constructor() {}
+
+ analyzeData(blob: Blob) {
+ blobToBase64(blob)
+ .then((base64) => {
+ const requestOptions = {
+ method: "PUT", // Specify the request method
+ headers: { "Content-Type": "application/json" }, // Specify the content type
+ body: JSON.stringify({data: base64}), // Send the data in blob format
+ };
+
+ fetch(getApp().getExpressAddress() + `/api/speech/recognize`, requestOptions)
+ .then((response) => {
+ if (response.status === 200) {
+ console.log(`Speech recognized correctly`);
+ return response.text();
+ } else {
+ getApp().addInfoMessage("Error recognizing speech");
+ throw new Error("Error saving profile");
+ }
+ })
+ .then((text) => this.#executeCommand(text))
+ .catch((error) => console.error(error)); // Handle errors
+ })
+ .catch((error) => console.error(error));
+ }
+
+ #executeCommand(text) {
+ console.log(`Received speech command: ${text}`);
+
+ if (text.indexOf("olympus") === 0 ) {
+ this.#olympusCommand(text);
+ } else if (text.indexOf(getApp().getAWACSController()?.getCallsign().toLowerCase()) === 0) {
+ getApp().getAWACSController()?.executeCommand(text);
+ }
+ }
+
+ #olympusCommand(text) {
+
+ }
+}
diff --git a/frontend/react/src/constants/constants.ts b/frontend/react/src/constants/constants.ts
index 81649e71..86de3a97 100644
--- a/frontend/react/src/constants/constants.ts
+++ b/frontend/react/src/constants/constants.ts
@@ -287,6 +287,7 @@ export enum OlympusState {
AUDIO = "Audio",
AIRBASE = "Airbase",
GAME_MASTER = "Game master",
+ LOAD_FILES = "Load files"
}
export const NO_SUBSTATE = "No substate";
diff --git a/frontend/react/src/controllers/awacs.ts b/frontend/react/src/controllers/awacs.ts
new file mode 100644
index 00000000..da053f23
--- /dev/null
+++ b/frontend/react/src/controllers/awacs.ts
@@ -0,0 +1,107 @@
+import { getApp } from "../olympusapp";
+import { Coalition } from "../types/types";
+import { Unit } from "../unit/unit";
+import { bearing, coalitionToEnum, computeBearingRangeString, mToFt, rad2deg } from "../other/utils";
+
+const trackStrings = ["North", "North-East", "East", "South-East", "South", "South-West", "West", "North-West", "North"];
+const relTrackStrings = ["hot", "flank right", "beam right", "cold", "cold", "cold", "beam left", "flank left", "hot"];
+
+export class AWACSController {
+ #coalition: Coalition = "blue";
+ #callsign: string = "Magic";
+ #referenceUnit: Unit;
+
+ constructor() {}
+
+ executeCommand(text) {
+ if (text.indexOf("request picture") > 0) {
+ console.log("Requested AWACS picture");
+ const readout = this.createPicture(true);
+ getApp()
+ .getAudioManager()
+ .playText(readout.reduce((acc, line) => (acc += " " + line), ""));
+ }
+ }
+
+ createPicture(forTextToSpeech: boolean = false, unitName?: string) {
+ let readout: string[] = [];
+
+ const mapOptions = getApp().getMap().getOptions();
+ const activeGroups = Object.values(
+ getApp()
+ .getUnitsManager()
+ .computeClusters((unit) => unit.getCoalition() !== mapOptions.AWACSCoalition, 6) ?? {}
+ );
+ const bullseyes = getApp().getMissionManager().getBullseyes();
+ const referenceUnit: Unit | undefined = unitName ? undefined : this.#referenceUnit; //TODO
+
+ if (bullseyes) {
+ if (referenceUnit !== undefined) {
+ readout.push(`${this.#callsign}, ${activeGroups.length} group${activeGroups.length > 1 ? "s" : ""}`);
+ readout.push(
+ ...activeGroups.map((group, idx) => {
+ let order = "th";
+ if (idx == 0) order = "st";
+ else if (idx == 1) order = "nd";
+ else if (idx == 2) order = "rd";
+
+ let trackDegs =
+ bearing(group[0].getPosition().lat, group[0].getPosition().lng, referenceUnit.getPosition().lat, referenceUnit.getPosition().lng) -
+ rad2deg(group[0].getTrack());
+ if (trackDegs < 0) trackDegs += 360;
+ if (trackDegs > 360) trackDegs -= 360;
+ let trackIndex = Math.round(trackDegs / 45);
+
+ let groupLine = `${activeGroups.length > 1 ? idx + 1 + "" + order + " group" : "Single group"} bullseye ${computeBearingRangeString(bullseyes[coalitionToEnum(mapOptions.AWACSCoalition)].getLatLng(), group[0].getPosition()).replace("/", " ")}, ${(mToFt(group[0].getPosition().alt ?? 0) / 1000).toFixed()} thousand, ${relTrackStrings[trackIndex]}`;
+
+ if (group.find((unit) => unit.getCoalition() === "neutral")) groupLine += ", bogey";
+ else groupLine += ", hostile";
+
+ return groupLine;
+ })
+ );
+ } else {
+ readout.push(`${this.#callsign}, ${activeGroups.length} group${activeGroups.length > 1 ? "s" : ""}`);
+ readout.push(
+ ...activeGroups.map((group, idx) => {
+ let order = "th";
+ if (idx == 0) order = "st";
+ else if (idx == 1) order = "nd";
+ else if (idx == 2) order = "rd";
+
+ let trackDegs = rad2deg(group[0].getTrack());
+ if (trackDegs < 0) trackDegs += 360;
+ let trackIndex = Math.round(trackDegs / 45);
+
+ let groupLine = `${activeGroups.length > 1 ? idx + 1 + "" + order + " group" : "Single group"} bullseye ${computeBearingRangeString(bullseyes[coalitionToEnum(mapOptions.AWACSCoalition)].getLatLng(), group[0].getPosition()).replace("/", " ")}, ${(mToFt(group[0].getPosition().alt ?? 0) / 1000).toFixed()} thousand, track ${trackStrings[trackIndex]}`;
+
+ if (group.find((unit) => unit.getCoalition() === "neutral")) groupLine += ", bogey";
+ else groupLine += ", hostile";
+
+ return groupLine;
+ })
+ );
+ }
+ }
+
+ return readout;
+ }
+
+ createBogeyDope(forTextToSpeech: boolean = false, unitName: string) {}
+
+ setCallsign(callsign: string) {
+ this.#callsign = callsign;
+ }
+
+ getCallsign() {
+ return this.#callsign;
+ }
+
+ setCoalition(coalition: Coalition) {
+ this.#coalition = coalition;
+ }
+
+ getCoalition() {
+ return this.#coalition;
+ }
+}
diff --git a/frontend/react/src/events.ts b/frontend/react/src/events.ts
index c2a56123..651b06f3 100644
--- a/frontend/react/src/events.ts
+++ b/frontend/react/src/events.ts
@@ -1,7 +1,7 @@
import { AudioSink } from "./audio/audiosink";
import { AudioSource } from "./audio/audiosource";
import { OlympusState, OlympusSubState } from "./constants/constants";
-import { CommandModeOptions, OlympusConfig, ServerStatus, SpawnRequestTable } from "./interfaces";
+import { CommandModeOptions, OlympusConfig, ServerStatus, SessionData, SpawnRequestTable } from "./interfaces";
import { CoalitionCircle } from "./map/coalitionarea/coalitioncircle";
import { CoalitionPolygon } from "./map/coalitionarea/coalitionpolygon";
import { Airbase } from "./mission/airbase";
@@ -150,6 +150,19 @@ export class ModalEvent {
}
}
+export class SessionDataLoadedEvent {
+ static on(callback: (sessionData: SessionData) => void) {
+ document.addEventListener(this.name, (ev: CustomEventInit) => {
+ callback(ev.detail.sessionData);
+ });
+ }
+
+ static dispatch(sessionData: SessionData) {
+ document.dispatchEvent(new CustomEvent(this.name, { detail: { sessionData } }));
+ console.log(`Event ${this.name} dispatched`);
+ }
+}
+
/************** Map events ***************/
export class MouseMovedEvent {
static on(callback: (latlng: LatLng, elevation: number) => void) {
diff --git a/frontend/react/src/interfaces.ts b/frontend/react/src/interfaces.ts
index 34758ceb..90904d1f 100644
--- a/frontend/react/src/interfaces.ts
+++ b/frontend/react/src/interfaces.ts
@@ -29,9 +29,15 @@ export interface OlympusConfig {
profiles?: ProfileOptions;
}
+export interface SessionData {
+ radios?: { frequency: number; modulation: number }[];
+ fileSources?: { filename: string; volume: number }[];
+ unitSinks?: {ID: number}[];
+}
+
export interface ProfileOptions {
- mapOptions: MapOptions,
- shortcuts: {[key: string]: ShortcutOptions}
+ mapOptions: MapOptions;
+ shortcuts: { [key: string]: ShortcutOptions };
}
export interface ContextMenuOption {
@@ -100,7 +106,7 @@ export interface SpawnRequestTable {
coalition: string;
unit: UnitSpawnTable;
amount: number;
- quickAccessName?: string
+ quickAccessName?: string;
}
export interface EffectRequestTable {
diff --git a/frontend/react/src/mission/missionmanager.ts b/frontend/react/src/mission/missionmanager.ts
index 82b2b646..f95ff5de 100644
--- a/frontend/react/src/mission/missionmanager.ts
+++ b/frontend/react/src/mission/missionmanager.ts
@@ -37,7 +37,6 @@ export class MissionManager {
AppStateChangedEvent.on((state, subState) => {
if (this.getSelectedAirbase() !== null) AirbaseSelectedEvent.dispatch(null);
})
-
}
/** Update location of bullseyes
diff --git a/frontend/react/src/olympusapp.ts b/frontend/react/src/olympusapp.ts
index c84e9008..d7cc97c7 100644
--- a/frontend/react/src/olympusapp.ts
+++ b/frontend/react/src/olympusapp.ts
@@ -23,6 +23,8 @@ import { AudioManager } from "./audio/audiomanager";
import { NO_SUBSTATE, OlympusState, OlympusSubState } from "./constants/constants";
import { AppStateChangedEvent, ConfigLoadedEvent, InfoPopupEvent, MapOptionsChangedEvent, SelectedUnitsChangedEvent, ShortcutsChangedEvent } from "./events";
import { OlympusConfig, ProfileOptions } from "./interfaces";
+import { AWACSController } from "./controllers/awacs";
+import { SessionDataManager } from "./sessiondata";
export var VERSION = "{{OLYMPUS_VERSION_NUMBER}}";
export var IP = window.location.toString();
@@ -46,8 +48,12 @@ export class OlympusApp {
#unitsManager: UnitsManager | null = null;
#weaponsManager: WeaponsManager | null = null;
#audioManager: AudioManager | null = null;
+ #sessionDataManager: SessionDataManager | null = null;
//#pluginsManager: // TODO
+ /* Controllers */
+ #AWACSController: AWACSController | null = null;
+
constructor() {
SelectedUnitsChangedEvent.on((selectedUnits) => {
if (selectedUnits.length > 0) this.setState(OlympusState.UNIT_CONTROL);
@@ -86,12 +92,20 @@ export class OlympusApp {
return this.#audioManager as AudioManager;
}
+ getSessionDataManager() {
+ return this.#sessionDataManager as SessionDataManager;
+ }
+
/* TODO
getPluginsManager() {
return null // this.#pluginsManager as PluginsManager;
}
*/
+ getAWACSController() {
+ return this.#AWACSController;
+ }
+
getExpressAddress() {
return `${window.location.href.split("?")[0].replace("vite/", "").replace("vite", "")}express`;
}
@@ -103,6 +117,7 @@ export class OlympusApp {
start() {
/* Initialize base functionalitites */
this.#shortcutManager = new ShortcutManager(); /* Keep first */
+ this.#sessionDataManager = new SessionDataManager();
this.#map = new Map("map-container");
@@ -112,6 +127,9 @@ export class OlympusApp {
this.#weaponsManager = new WeaponsManager();
this.#audioManager = new AudioManager();
+ /* Controllers */
+ this.#AWACSController = new AWACSController();
+
/* Set the address of the server */
this.getServerManager().setAddress(this.getBackendAddress());
this.getAudioManager().setAddress(this.getExpressAddress());
@@ -207,7 +225,7 @@ export class OlympusApp {
.then((response) => {
if (response.status === 200) {
console.log(`Profile ${this.#profileName} reset correctly`);
- location.reload()
+ location.reload();
} else {
this.addInfoMessage("Error resetting profile");
throw new Error("Error resetting profile");
@@ -228,7 +246,7 @@ export class OlympusApp {
.then((response) => {
if (response.status === 200) {
console.log(`All profiles reset correctly`);
- location.reload()
+ location.reload();
} else {
this.addInfoMessage("Error resetting profiles");
throw new Error("Error resetting profiles");
@@ -243,6 +261,10 @@ export class OlympusApp {
else return null;
}
+ getProfileName() {
+ return this.#profileName;
+ }
+
loadProfile() {
const profile = this.getProfile();
if (profile) {
@@ -280,4 +302,6 @@ export class OlympusApp {
InfoPopupEvent.dispatch(this.#infoMessages);
}, 5000);
}
+
+
}
diff --git a/frontend/react/src/other/utils.ts b/frontend/react/src/other/utils.ts
index 307c9d4f..b9ca3b9b 100644
--- a/frontend/react/src/other/utils.ts
+++ b/frontend/react/src/other/utils.ts
@@ -397,3 +397,11 @@ export function wait(time) {
export function computeBearingRangeString(latlng1, latlng2) {
return `${bearing(latlng1.lat, latlng1.lng, latlng2.lat, latlng2.lng).toFixed()}/${(latlng1.distanceTo(latlng2) / 1852).toFixed(0)}`;
}
+
+export function blobToBase64(blob) {
+ return new Promise((resolve: (value: string) => void, _) => {
+ const reader = new FileReader();
+ reader.onloadend = () => resolve(reader.result as string);
+ reader.readAsDataURL(blob);
+ });
+}
\ No newline at end of file
diff --git a/frontend/react/src/server/servermanager.ts b/frontend/react/src/server/servermanager.ts
index 18fa6122..8e2f00e6 100644
--- a/frontend/react/src/server/servermanager.ts
+++ b/frontend/react/src/server/servermanager.ts
@@ -668,7 +668,10 @@ export class ServerManager {
checkSessionHash(newSessionHash: string) {
if (this.#sessionHash != null) {
if (newSessionHash !== this.#sessionHash) location.reload();
- } else this.#sessionHash = newSessionHash;
+ } else {
+ this.#sessionHash = newSessionHash;
+ getApp().getSessionDataManager().loadSessionData(newSessionHash);
+ }
}
setConnected(newConnected: boolean) {
diff --git a/frontend/react/src/sessiondata.ts b/frontend/react/src/sessiondata.ts
new file mode 100644
index 00000000..b257a12b
--- /dev/null
+++ b/frontend/react/src/sessiondata.ts
@@ -0,0 +1,112 @@
+import { FileSource } from "./audio/filesource";
+import { RadioSink } from "./audio/radiosink";
+import { UnitSink } from "./audio/unitsink";
+import { AudioSinksChangedEvent, AudioSourcesChangedEvent, SessionDataLoadedEvent as SessionDataChangedEvent } from "./events";
+import { SessionData } from "./interfaces";
+import { getApp } from "./olympusapp";
+
+export class SessionDataManager {
+ #sessionData: SessionData = {};
+ #sessionHash: string = "";
+ #saveSessionDataTimeout: number | null = null;
+
+ constructor() {
+ AudioSinksChangedEvent.on((audioSinks) => {
+ if (getApp().getAudioManager().isRunning()) {
+ this.#sessionData.radios = audioSinks
+ .filter((sink) => sink instanceof RadioSink)
+ .map((radioSink) => {
+ return {
+ frequency: radioSink.getFrequency(),
+ modulation: radioSink.getModulation(),
+ };
+ });
+
+ this.#sessionData.unitSinks = audioSinks
+ .filter((sink) => sink instanceof UnitSink)
+ .map((unitSink) => {
+ return {
+ ID: unitSink.getUnit().ID
+ };
+ });
+
+ this.#saveSessionData();
+ }
+ });
+
+ AudioSourcesChangedEvent.on((audioSources) => {
+ if (getApp().getAudioManager().isRunning()) {
+ this.#sessionData.fileSources = audioSources
+ .filter((sink) => sink instanceof FileSource)
+ .map((fileSource) => {
+ return { filename: fileSource.getFilename(), volume: fileSource.getVolume() };
+ });
+ this.#saveSessionData();
+ }
+ });
+ }
+
+ loadSessionData(sessionHash?: string) {
+ if (sessionHash) this.#sessionHash = sessionHash;
+ if (this.#sessionHash === undefined) {
+ console.error("Trying to load session data but no session hash provided");
+ return;
+ }
+
+ const requestOptions = {
+ method: "PUT", // Specify the request method
+ headers: { "Content-Type": "application/json" }, // Specify the content type
+ body: JSON.stringify({ sessionHash }), // Send the data in JSON format
+ };
+
+ fetch(getApp().getExpressAddress() + `/resources/sessiondata/load/${getApp().getProfileName()}`, requestOptions)
+ .then((response) => {
+ if (response.status === 200) {
+ console.log(`Session data for profile ${getApp().getProfileName()} and session hash ${sessionHash} loaded correctly`);
+ return response.json();
+ } else {
+ getApp().addInfoMessage("No session data found for this profile");
+ throw new Error("No session data found for this profile");
+ }
+ }) // Parse the response as JSON
+ .then((sessionData) => {
+ this.#sessionData = sessionData;
+ this.#applySessionData();
+ SessionDataChangedEvent.dispatch(this.#sessionData);
+ })
+ .catch((error) => console.error(error)); // Handle errors
+ }
+
+ getSessionData() {
+ return this.#sessionData;
+ }
+
+ #saveSessionData() {
+ if (this.#saveSessionDataTimeout) window.clearTimeout(this.#saveSessionDataTimeout);
+ this.#saveSessionDataTimeout = window.setTimeout(() => {
+ const requestOptions = {
+ method: "PUT", // Specify the request method
+ headers: { "Content-Type": "application/json" }, // Specify the content type
+ body: JSON.stringify({ sessionHash: this.#sessionHash, sessionData: this.#sessionData }), // Send the data in JSON format
+ };
+
+ fetch(getApp().getExpressAddress() + `/resources/sessiondata/save/${getApp().getProfileName()}`, requestOptions)
+ .then((response) => {
+ if (response.status === 200) {
+ console.log(`Session data for profile ${getApp().getProfileName()} and session hash ${this.#sessionHash} saved correctly`);
+ console.log(this.#sessionData);
+ SessionDataChangedEvent.dispatch(this.#sessionData);
+ } else {
+ getApp().addInfoMessage("Error loading session data");
+ throw new Error("Error loading session data");
+ }
+ }) // Parse the response as JSON
+ .catch((error) => console.error(error)); // Handle errors
+ this.#saveSessionDataTimeout = null;
+ }, 1000);
+ }
+
+ #applySessionData() {
+ let asd = 1;
+ }
+}
diff --git a/frontend/react/src/ui/modals/filesourceloadprompt.tsx b/frontend/react/src/ui/modals/filesourceloadprompt.tsx
new file mode 100644
index 00000000..c9d62afc
--- /dev/null
+++ b/frontend/react/src/ui/modals/filesourceloadprompt.tsx
@@ -0,0 +1,129 @@
+import React, { useEffect, useState } from "react";
+import { Modal } from "./components/modal";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faArrowRight, faCheck, faUpload } from "@fortawesome/free-solid-svg-icons";
+import { getApp } from "../../olympusapp";
+import { OlympusState } from "../../constants/constants";
+import { SessionDataLoadedEvent } from "../../events";
+
+export function FileSourceLoadPrompt(props: { open: boolean }) {
+ const [files, setFiles] = useState([] as { filename: string; volume: number }[]);
+ const [loaded, setLoaded] = useState([] as boolean[]);
+
+ useEffect(() => {
+ SessionDataLoadedEvent.on((sessionData) => {
+ if (getApp().getState() === OlympusState.LOAD_FILES) return; // TODO don't like this, is hacky Should avoid reading state directly
+ if (sessionData.fileSources) {
+ setFiles([...sessionData.fileSources]);
+ setLoaded(
+ sessionData.fileSources.map((file) => {
+ return false;
+ })
+ );
+ }
+ });
+ }, []);
+
+ return (
+