From c9b143b5e0dfbc27c845a25d4587d8ba1c85f9f6 Mon Sep 17 00:00:00 2001 From: Davide Passoni Date: Thu, 27 Mar 2025 13:16:39 +0100 Subject: [PATCH] feat: Multiple improvements to audio backend --- frontend/react/src/audio/audiomanager.ts | 286 +++++++++++---- frontend/react/src/audio/audiopacket.ts | 2 +- frontend/react/src/audio/radiosink.ts | 12 + frontend/react/src/audio/unitsink.ts | 20 +- frontend/react/src/constants/constants.ts | 9 +- frontend/react/src/events.ts | 45 ++- frontend/react/src/interfaces.ts | 7 +- frontend/react/src/olympusapp.ts | 18 +- frontend/react/src/types/types.ts | 24 ++ frontend/react/src/ui/panels/audiomenu.tsx | 184 +++++++--- .../ui/panels/components/radiosinkpanel.tsx | 59 +-- .../src/ui/panels/components/sourcepanel.tsx | 6 +- .../ui/panels/components/unitsinkpanel.tsx | 15 +- .../react/src/ui/panels/controlspanel.tsx | 8 - frontend/react/src/ui/panels/header.tsx | 50 ++- frontend/react/src/ui/panels/infobar.tsx | 9 +- frontend/react/src/ui/panels/maptoolbar.tsx | 100 ++++-- frontend/react/src/ui/panels/minimappanel.tsx | 4 +- .../src/ui/panels/radiossummarypanel.tsx | 25 +- .../react/src/ui/panels/unitcontrolmenu.tsx | 340 +++++++++--------- frontend/react/src/ui/ui.tsx | 2 - frontend/server/src/audio/audiopacket.ts | 2 +- frontend/server/src/audio/srshandler.ts | 36 +- 23 files changed, 819 insertions(+), 444 deletions(-) diff --git a/frontend/react/src/audio/audiomanager.ts b/frontend/react/src/audio/audiomanager.ts index be88f287..d5b092ed 100644 --- a/frontend/react/src/audio/audiomanager.ts +++ b/frontend/react/src/audio/audiomanager.ts @@ -1,4 +1,4 @@ -import { AudioMessageType, BLUE_COMMANDER, GAME_MASTER, OlympusState, RED_COMMANDER } from "../constants/constants"; +import { AudioManagerState, AudioMessageType, BLUE_COMMANDER, GAME_MASTER, OlympusState, RED_COMMANDER } from "../constants/constants"; import { MicrophoneSource } from "./microphonesource"; import { RadioSink } from "./radiosink"; import { getApp } from "../olympusapp"; @@ -16,6 +16,7 @@ import { AudioManagerInputChangedEvent, AudioManagerOutputChangedEvent, AudioManagerStateChangedEvent, + AudioOptionsChangedEvent, AudioSinksChangedEvent, AudioSourcesChangedEvent, CommandModeOptionsChangedEvent, @@ -24,13 +25,14 @@ import { } from "../events"; import { CommandModeOptions, OlympusConfig } from "../interfaces"; import { TextToSpeechSource } from "./texttospeechsource"; -import { Coalition } from "../types/types"; +import { AudioOptions, Coalition, SRSClientData } from "../types/types"; export class AudioManager { #audioContext: AudioContext; #devices: MediaDeviceInfo[] = []; #input: MediaDeviceInfo; #output: MediaDeviceInfo; + #options: AudioOptions = { input: "", output: "" }; /* The audio sinks used to transmit the audio stream to the SRS backend */ #sinks: AudioSink[] = []; @@ -40,17 +42,19 @@ export class AudioManager { /* The audio backend must be manually started so that the browser can detect the user is enabling audio. Otherwise, no playback will be performed. */ - #running: boolean = false; + #state: string = AudioManagerState.STOPPED; #port: number; #endpoint: string; #socket: WebSocket | null = null; #guid: string = makeID(22); - #SRSClientUnitIDs: number[] = []; + #SRSClientsData: SRSClientData[] = []; #syncInterval: number; #speechRecognition: boolean = true; #internalTextToSpeechSource: TextToSpeechSource; #coalition: Coalition = "blue"; #commandMode: string = BLUE_COMMANDER; + #connectionCheckTimeout: number; + #receivedPackets: number = 0; constructor() { ConfigLoadedEvent.on((config: OlympusConfig) => { @@ -86,15 +90,24 @@ export class AudioManager { } start() { + if (this.#state === AudioManagerState.ERROR) { + console.error("The audio backend is in error state, cannot start"); + getApp().addInfoMessage("The audio backend is in error state, cannot start"); + return; + } + + if (this.#state === AudioManagerState.RUNNING) { + console.error("The audio backend is already running, cannot start again"); + } + + getApp().addInfoMessage("Starting audio backend, please wait"); + this.#syncInterval = window.setInterval(() => { this.#syncRadioSettings(); }, 1000); this.#audioContext = new AudioContext({ sampleRate: 16000 }); - //@ts-ignore - if (this.#output) this.#audioContext.setSinkId(this.#output.deviceId); - /* Connect the audio websocket */ let res = location.toString().match(/(?:http|https):\/\/(.+):/); if (res === null) res = location.toString().match(/(?:http|https):\/\/(.+)/); @@ -122,11 +135,77 @@ export class AudioManager { /* Log any websocket errors */ this.#socket.addEventListener("error", (event) => { - console.log("An error occurred while connecting the WebSocket: " + event); + console.log("An error occurred while connecting to the audio backend WebSocket"); + getApp().addInfoMessage("An error occurred while connecting to the audio backend WebSocket"); + this.error(); }); /* Handle the reception of a new message */ this.#socket.addEventListener("message", (event) => { + this.#receivedPackets++; + + /* Extract the clients data */ + event.data.arrayBuffer().then((packetArray) => { + const packetUint8Array = new Uint8Array(packetArray); + if (packetUint8Array[0] === MessageType.clientsData) { + const newSRSClientsData = JSON.parse(new TextDecoder().decode(packetUint8Array.slice(1))).clientsData; + + /* Check if anything has changed with the SRSClients */ + let clientsDataChanged = false; + /* Check if the length of the clients data has changed */ + if (newSRSClientsData.length !== this.#SRSClientsData.length) { + clientsDataChanged = true; + } else { + newSRSClientsData.forEach((newClientData) => { + /* Check if the length is the same, but the clients names have changed */ + let clientData = this.#SRSClientsData.find((clientData) => newClientData.name === clientData.name); + if (clientData === undefined) clientsDataChanged = true; + else { + /* Check if any of the data has changed */ + if ( + clientData.coalition !== newClientData.coalition || + clientData.unitID !== newClientData.unitID || + Object.keys(clientData.iff).find((key) => clientData.iff[key] !== newClientData.iff[key]) !== undefined || + clientData.radios.find( + (radio, idx) => radio.frequency !== newClientData.radios[idx].frequency || radio.modulation !== newClientData.radios[idx].modulation + ) !== undefined + ) + clientsDataChanged = true; + } + }); + } + + /* If the clients data has changed, dispatch the event */ + if (clientsDataChanged) { + this.#SRSClientsData = newSRSClientsData; + SRSClientsChangedEvent.dispatch(this.#SRSClientsData); + } + + /* Update the number of connected clients for each radio */ + this.#sinks + .filter((sink) => sink instanceof RadioSink) + .forEach((radio) => { + let connectedClients = 0; + /* Check if any of the radios of this client is tuned to the same frequency, has the same modulation, and is of the same coalition */ + this.#SRSClientsData.forEach((clientData: SRSClientData) => { + let clientConnected = false; + clientData.radios.forEach((radioData) => { + if ( + clientData.coalition === coalitionToEnum(this.#coalition) && + radioData.frequency === radio.getFrequency() && + radioData.modulation === radio.getModulation() + ) + clientConnected = true; + }); + if (clientConnected) connectedClients++; + }); + + radio.setConnectedClients(connectedClients); + }); + } + }); + + /* Iterate over the radios. We iterate over the radios first so that a new copy of the audio packet is created for each pipeline */ this.#sinks.forEach(async (sink) => { if (sink instanceof RadioSink) { /* Extract the audio data as array */ @@ -151,100 +230,135 @@ export class AudioManager { sink.playBuffer(dst); } }); - } else { - this.#SRSClientUnitIDs = JSON.parse(new TextDecoder().decode(packetUint8Array.slice(1))).unitIDs; - SRSClientsChangedEvent.dispatch(); } } }); }); - /* Add the microphone source and connect it directly to the radio */ - const microphoneSource = new MicrophoneSource(this.#input); - microphoneSource.initialize().then(() => { - this.#sinks.forEach((sink) => { - if (sink instanceof RadioSink) microphoneSource.connect(sink); - }); - this.#sources.push(microphoneSource); - AudioSourcesChangedEvent.dispatch(getApp().getAudioManager().getSources()); + navigator.mediaDevices.enumerateDevices().then((devices) => { + this.#devices = devices; + AudioManagerDevicesChangedEvent.dispatch(devices); - let sessionRadios = getApp().getSessionDataManager().getSessionData().radios; - if (sessionRadios) { - /* Load session radios */ - sessionRadios.forEach((options) => { + if (this.#options.input) { + let newInput = this.#devices.find((device) => device.deviceId === this.#options.input); + if (newInput) { + this.#input = newInput; + AudioManagerInputChangedEvent.dispatch(newInput); + } + } + + if (this.#options.output) { + let newOutput = this.#devices.find((device) => device.deviceId === this.#options.output); + if (newOutput) { + this.#output = newOutput; + AudioManagerOutputChangedEvent.dispatch(newOutput); + } + } + + /* Add the microphone source and connect it directly to the radio */ + const microphoneSource = new MicrophoneSource(this.#input); + microphoneSource.initialize().then(() => { + this.#sinks.forEach((sink) => { + if (sink instanceof RadioSink) microphoneSource.connect(sink); + }); + this.#sources.push(microphoneSource); + AudioSourcesChangedEvent.dispatch(getApp().getAudioManager().getSources()); + + 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); + newRadio?.setPan(options.pan); + }); + } else { + /* Add two default radios and connect to the microphone*/ let newRadio = this.addRadio(); - newRadio?.setFrequency(options.frequency); - newRadio?.setModulation(options.modulation); - newRadio?.setPan(options.pan); - }); - } else { - /* Add two default radios and connect to the microphone*/ - let newRadio = this.addRadio(); - this.#sources.find((source) => source instanceof MicrophoneSource)?.connect(newRadio); - this.#sources.find((source) => source instanceof TextToSpeechSource)?.connect(newRadio); - newRadio.setPan(-1); + newRadio.setPan(-1); - newRadio = this.addRadio(); - this.#sources.find((source) => source instanceof MicrophoneSource)?.connect(newRadio); - this.#sources.find((source) => source instanceof TextToSpeechSource)?.connect(newRadio); - newRadio.setPan(1); - } + newRadio = this.addRadio(); + newRadio.setPan(1); + } - let sessionFileSources = getApp().getSessionDataManager().getSessionData().fileSources; - if (sessionFileSources) { - /* Load file sources */ - sessionFileSources.forEach((options) => { - this.addFileSource(); - }); - } + let sessionFileSources = getApp().getSessionDataManager().getSessionData().fileSources; + if (sessionFileSources) { + /* Load file sources */ + sessionFileSources.forEach((options) => { + this.addFileSource(); + }); + } - let sessionUnitSinks = getApp().getSessionDataManager().getSessionData().unitSinks; - if (sessionUnitSinks) { - /* Load unit sinks */ - sessionUnitSinks.forEach((options) => { - let unit = getApp().getUnitsManager().getUnitByID(options.ID); - if (unit) { - this.addUnitSink(unit); - } - }); - } + let sessionUnitSinks = getApp().getSessionDataManager().getSessionData().unitSinks; + if (sessionUnitSinks) { + /* Load unit sinks */ + sessionUnitSinks.forEach((options) => { + let unit = getApp().getUnitsManager().getUnitByID(options.ID); + if (unit) { + this.addUnitSink(unit); + } + }); + } - let sessionConnections = getApp().getSessionDataManager().getSessionData().connections; - if (sessionConnections) { - sessionConnections.forEach((connection) => { - if (connection[0] < this.#sources.length && connection[1] < this.#sinks.length) this.#sources[connection[0]]?.connect(this.#sinks[connection[1]]); - }); - } + let sessionConnections = getApp().getSessionDataManager().getSessionData().connections; + if (sessionConnections) { + sessionConnections.forEach((connection) => { + if (connection[0] < this.#sources.length && connection[1] < this.#sinks.length) this.#sources[connection[0]]?.connect(this.#sinks[connection[1]]); + }); + } - this.#running = true; - AudioManagerStateChangedEvent.dispatch(this.#running); + if (this.#state !== AudioManagerState.ERROR) { + this.#state = AudioManagerState.RUNNING; + AudioManagerStateChangedEvent.dispatch(this.#state); + } + }); + + //@ts-ignore + if (this.#output) this.#audioContext.setSinkId(this.#output.deviceId); }); const textToSpeechSource = new TextToSpeechSource(); this.#sources.push(textToSpeechSource); - navigator.mediaDevices.enumerateDevices().then((devices) => { - this.#devices = devices; - AudioManagerDevicesChangedEvent.dispatch(devices); - }); - this.#internalTextToSpeechSource = new TextToSpeechSource(); + + /* Check if the audio backend is receiving updates from the backend every 10 seconds */ + this.#connectionCheckTimeout = window.setTimeout(() => { + if (this.#receivedPackets === 0) { + console.error("The audio backend is not receiving any data from the backend, stopping the audio backend"); + getApp().addInfoMessage("The audio backend is not receiving any data from the backend, stopping the audio backend"); + this.error(); + } + }, 10000); } stop() { /* Stop everything and send update event */ - this.#running = false; this.#sources.forEach((source) => source.disconnect()); this.#sinks.forEach((sink) => sink.disconnect()); this.#sources = []; this.#sinks = []; this.#socket?.close(); + window.clearInterval(this.#connectionCheckTimeout); + window.clearInterval(this.#syncInterval); AudioSourcesChangedEvent.dispatch(this.#sources); AudioSinksChangedEvent.dispatch(this.#sinks); - AudioManagerStateChangedEvent.dispatch(this.#running); + + if (this.#state !== AudioManagerState.ERROR) { + this.#state = AudioManagerState.STOPPED; + AudioManagerStateChangedEvent.dispatch(this.#state); + } + } + + error() { + this.stop(); + + this.#state = AudioManagerState.ERROR; + AudioManagerStateChangedEvent.dispatch(this.#state); } setPort(port) { @@ -288,10 +402,24 @@ export class AudioManager { 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); + this.#sources.find((source) => source instanceof TextToSpeechSource)?.connect(newRadio); + AudioSinksChangedEvent.dispatch(this.#sinks); return newRadio; } + tuneNewRadio(frequency, modulation) { + /* Check if a radio with the same frequency and modulation already exists */ + let radio = this.#sinks.find((sink) => sink instanceof RadioSink && sink.getFrequency() === frequency && sink.getModulation() === modulation); + if (radio === undefined) { + let newRadio = this.addRadio(); + newRadio.setFrequency(frequency); + newRadio.setModulation(modulation); + } + } + getSinks() { return this.#sinks; } @@ -325,12 +453,12 @@ export class AudioManager { return this.#audioContext; } - getSRSClientsUnitIDs() { - return this.#SRSClientUnitIDs; + getSRSClientsData() { + return this.#SRSClientsData; } isRunning() { - return this.#running; + return this.#state; } setInput(input: MediaDeviceInfo) { @@ -339,6 +467,8 @@ export class AudioManager { AudioManagerInputChangedEvent.dispatch(input); this.stop(); this.start(); + this.#options.input = input.deviceId; + AudioOptionsChangedEvent.dispatch(this.#options); } else { console.error("Requested input device is not in devices list"); } @@ -350,6 +480,8 @@ export class AudioManager { AudioManagerOutputChangedEvent.dispatch(output); this.stop(); this.start(); + this.#options.output = output.deviceId; + AudioOptionsChangedEvent.dispatch(this.#options); } else { console.error("Requested output device is not in devices list"); } @@ -382,6 +514,14 @@ export class AudioManager { return this.#coalition; } + setOptions(options: AudioOptions) { + this.#options = options; + } + + getOptions() { + return this.#options; + } + #syncRadioSettings() { /* Send the radio settings of each radio to the SRS backend */ let message = { diff --git a/frontend/react/src/audio/audiopacket.ts b/frontend/react/src/audio/audiopacket.ts index 2727fbfc..aa3bf3c4 100644 --- a/frontend/react/src/audio/audiopacket.ts +++ b/frontend/react/src/audio/audiopacket.ts @@ -7,7 +7,7 @@ var packetID = 0; export enum MessageType { audio, settings, - unitIDs + clientsData } export class AudioPacket { diff --git a/frontend/react/src/audio/radiosink.ts b/frontend/react/src/audio/radiosink.ts index 0dcbe675..b54c6a67 100644 --- a/frontend/react/src/audio/radiosink.ts +++ b/frontend/react/src/audio/radiosink.ts @@ -25,6 +25,7 @@ export class RadioSink extends AudioSink { #transmittingUnit: Unit | undefined; #pan: number = 0; #playbackPipeline: PlaybackPipeline; + #connectedClients: number; speechDataAvailable: (blob: Blob) => void = (blob) => {}; constructor() { @@ -190,4 +191,15 @@ export class RadioSink extends AudioSink { playBuffer(arrayBuffer) { this.#playbackPipeline.playBuffer(arrayBuffer); } + + setConnectedClients(clientsNumber: number) { + if (this.#connectedClients !== clientsNumber) { + this.#connectedClients = clientsNumber; + AudioSinksChangedEvent.dispatch(getApp().getAudioManager().getSinks()); + } + } + + getConnectedClients() { + return this.#connectedClients; + } } diff --git a/frontend/react/src/audio/unitsink.ts b/frontend/react/src/audio/unitsink.ts index 087a56ae..627aa7c7 100644 --- a/frontend/react/src/audio/unitsink.ts +++ b/frontend/react/src/audio/unitsink.ts @@ -3,6 +3,7 @@ import { getApp } from "../olympusapp"; import { Unit } from "../unit/unit"; import { AudioUnitPipeline } from "./audiounitpipeline"; import { AudioSinksChangedEvent, SRSClientsChangedEvent } from "../events"; +import { SRSClientData } from "../types/types"; /* Unit sink to implement a "loudspeaker" external sound. Useful for stuff like 5MC calls, air sirens, scramble calls and so on. Ideally, one may want to move this code to the backend*/ @@ -32,20 +33,23 @@ export class UnitSink extends AudioSink { #updatePipelines() { getApp() .getAudioManager() - .getSRSClientsUnitIDs() - .forEach((unitID) => { + .getSRSClientsData() + .forEach((clientData: SRSClientData) => { + const unitID = clientData.unitID; if (unitID !== 0 && !(unitID in this.#unitPipelines)) { this.#unitPipelines[unitID] = new AudioUnitPipeline(this.#unit, unitID, this.getInputNode()); this.#unitPipelines[unitID].setPtt(false); this.#unitPipelines[unitID].setMaxDistance(this.#maxDistance); - console.log(`Added unit pipeline for unitID ${unitID} ` ) + console.log(`Added unit pipeline for unitID ${unitID} `); } }); Object.keys(this.#unitPipelines).forEach((unitID) => { - if (!(getApp().getAudioManager().getSRSClientsUnitIDs().includes(parseInt(unitID)))) { - delete this.#unitPipelines[unitID]; - } + const unitIDs = getApp() + .getAudioManager() + .getSRSClientsData() + .map((clientData) => clientData.unitID); + if (!unitIDs.includes(parseInt(unitID))) delete this.#unitPipelines[unitID]; }); } @@ -53,7 +57,7 @@ export class UnitSink extends AudioSink { this.#ptt = ptt; Object.values(this.#unitPipelines).forEach((pipeline) => { pipeline.setPtt(ptt); - }) + }); AudioSinksChangedEvent.dispatch(getApp().getAudioManager().getSinks()); } @@ -65,7 +69,7 @@ export class UnitSink extends AudioSink { this.#maxDistance = maxDistance; Object.values(this.#unitPipelines).forEach((pipeline) => { pipeline.setMaxDistance(maxDistance); - }) + }); AudioSinksChangedEvent.dispatch(getApp().getAudioManager().getSinks()); } diff --git a/frontend/react/src/constants/constants.ts b/frontend/react/src/constants/constants.ts index 9e825a4a..8264596b 100644 --- a/frontend/react/src/constants/constants.ts +++ b/frontend/react/src/constants/constants.ts @@ -1,5 +1,5 @@ import { LatLng, LatLngBounds } from "leaflet"; -import { Coalition, MapOptions } from "../types/types"; +import { MapOptions } from "../types/types"; import { CommandModeOptions } from "../interfaces"; import { ContextAction } from "../unit/contextaction"; import { @@ -981,3 +981,10 @@ export namespace ContextActions { { type: ContextActionType.ADMIN, code: "KeyC", ctrlKey: false, shiftKey: false, altKey: false } ); } + + +export enum AudioManagerState { + STOPPED = "Stopped", + RUNNING = "Running", + ERROR = "Error" +} diff --git a/frontend/react/src/events.ts b/frontend/react/src/events.ts index c0909f52..6dc49454 100644 --- a/frontend/react/src/events.ts +++ b/frontend/react/src/events.ts @@ -7,7 +7,7 @@ import { CoalitionPolygon } from "./map/coalitionarea/coalitionpolygon"; import { Airbase } from "./mission/airbase"; import { Bullseye } from "./mission/bullseye"; import { Shortcut } from "./shortcut/shortcut"; -import { Coalition, MapHiddenTypes, MapOptions } from "./types/types"; +import { AudioOptions, Coalition, MapHiddenTypes, MapOptions, SRSClientData } from "./types/types"; import { ContextAction } from "./unit/contextaction"; import { ContextActionSet } from "./unit/contextactionset"; import { Unit } from "./unit/unit"; @@ -195,6 +195,22 @@ export class BindShortcutRequestEvent { } } +export class AudioOptionsChangedEvent { + static on(callback: (audioOptions: AudioOptions) => void, singleShot = false) { + document.addEventListener( + this.name, + (ev: CustomEventInit) => { + callback(ev.detail.audioOptions); + }, + { once: singleShot } + ); + } + static dispatch(audioOptions: AudioOptions) { + document.dispatchEvent(new CustomEvent(this.name, { detail: { audioOptions } })); + if (DEBUG) console.log(`Event ${this.name} dispatched`); + } +} + export class ModalEvent { static on(callback: (modal: boolean) => void, singleShot = false) { document.addEventListener( @@ -295,7 +311,7 @@ export class MapOptionsChangedEvent { ); } - static dispatch(mapOptions: MapOptions, key?: (keyof MapOptions) | undefined) { + static dispatch(mapOptions: MapOptions, key?: keyof MapOptions | undefined) { document.dispatchEvent(new CustomEvent(this.name, { detail: { mapOptions, key: key } })); if (DEBUG) console.log(`Event ${this.name} dispatched`); } @@ -658,8 +674,8 @@ export class DrawingsInitEvent { ); } - static dispatch(drawingsData: any, navpointData?: any /*TODO*/) { - document.dispatchEvent(new CustomEvent(this.name, {detail: drawingsData})); + static dispatch(drawingsData: any, navpointData?: any /*TODO*/) { + document.dispatchEvent(new CustomEvent(this.name, { detail: drawingsData })); if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -722,24 +738,24 @@ export class AudioSinksChangedEvent { } export class SRSClientsChangedEvent { - static on(callback: () => void, singleShot = false) { + static on(callback: (clientsData: SRSClientData[]) => void, singleShot = false) { document.addEventListener( this.name, (ev: CustomEventInit) => { - callback(); + callback(ev.detail.clientsData); }, { once: singleShot } ); } - static dispatch() { - document.dispatchEvent(new CustomEvent(this.name)); + static dispatch(clientsData: SRSClientData[]) { + document.dispatchEvent(new CustomEvent(this.name, { detail: { clientsData } })); // Logging disabled since periodic } } export class AudioManagerStateChangedEvent { - static on(callback: (state: boolean) => void, singleShot = false) { + static on(callback: (state: string) => void, singleShot = false) { document.addEventListener( this.name, (ev: CustomEventInit) => { @@ -749,7 +765,7 @@ export class AudioManagerStateChangedEvent { ); } - static dispatch(state: boolean) { + static dispatch(state: string) { document.dispatchEvent(new CustomEvent(this.name, { detail: { state } })); if (DEBUG) console.log(`Event ${this.name} dispatched`); } @@ -895,12 +911,9 @@ export class WeaponsRefreshedEvent { export class CoordinatesFreezeEvent { static on(callback: () => void) { - document.addEventListener( - this.name, - (ev: CustomEventInit) => { - callback(); - } - ) + document.addEventListener(this.name, (ev: CustomEventInit) => { + callback(); + }); } static dispatch() { diff --git a/frontend/react/src/interfaces.ts b/frontend/react/src/interfaces.ts index af34f7dd..847c1a08 100644 --- a/frontend/react/src/interfaces.ts +++ b/frontend/react/src/interfaces.ts @@ -1,5 +1,5 @@ import { LatLng } from "leaflet"; -import { Coalition, MapOptions } from "./types/types"; +import { AudioOptions, Coalition, MapOptions } from "./types/types"; export interface OlympusConfig { /* Set by user */ @@ -64,8 +64,9 @@ export interface SessionData { } export interface ProfileOptions { - mapOptions: MapOptions; - shortcuts: { [key: string]: ShortcutOptions }; + mapOptions?: MapOptions; + shortcuts?: { [key: string]: ShortcutOptions }; + audioOptions?: AudioOptions; } export interface ContextMenuOption { diff --git a/frontend/react/src/olympusapp.ts b/frontend/react/src/olympusapp.ts index e11bde53..16f4a637 100644 --- a/frontend/react/src/olympusapp.ts +++ b/frontend/react/src/olympusapp.ts @@ -21,7 +21,7 @@ import { ServerManager } from "./server/servermanager"; import { AudioManager } from "./audio/audiomanager"; import { GAME_MASTER, LoginSubState, MAP_OPTIONS_DEFAULTS, NO_SUBSTATE, OlympusState, OlympusSubState, WarningSubstate } from "./constants/constants"; -import { AdminPasswordChangedEvent, AppStateChangedEvent, ConfigLoadedEvent, InfoPopupEvent, MapOptionsChangedEvent, SelectedUnitsChangedEvent, ShortcutsChangedEvent } from "./events"; +import { AdminPasswordChangedEvent, AppStateChangedEvent, AudioOptionsChangedEvent, ConfigLoadedEvent, InfoPopupEvent, MapOptionsChangedEvent, SelectedUnitsChangedEvent, ShortcutsChangedEvent } from "./events"; import { OlympusConfig } from "./interfaces"; import { SessionDataManager } from "./sessiondata"; import { ControllerManager } from "./controllers/controllermanager"; @@ -64,8 +64,9 @@ export class OlympusApp { else this.getState() === OlympusState.UNIT_CONTROL && this.setState(OlympusState.IDLE); }); - MapOptionsChangedEvent.on((options) => getApp().saveProfile()); - ShortcutsChangedEvent.on((options) => getApp().saveProfile()); + MapOptionsChangedEvent.on(() => getApp().saveProfile()); + ShortcutsChangedEvent.on(() => getApp().saveProfile()); + AudioOptionsChangedEvent.on(() => getApp().saveProfile()); } getMap() { @@ -212,6 +213,7 @@ export class OlympusApp { let profile = {}; profile["mapOptions"] = this.#map?.getOptions(); profile["shortcuts"] = this.#shortcutManager?.getShortcutsOptions(); + profile["audioOptions"] = this.#audioManager?.getOptions(); const requestOptions = { method: "PUT", // Specify the request method @@ -285,8 +287,10 @@ export class OlympusApp { const username = this.getServerManager().getUsername(); const profile = this.getProfile(); if (username && profile) { - this.#map?.setOptions( {...MAP_OPTIONS_DEFAULTS, ...profile.mapOptions}); - this.#shortcutManager?.setShortcutsOptions(profile.shortcuts); + if (profile.mapOptions) this.#map?.setOptions( {...MAP_OPTIONS_DEFAULTS, ...profile.mapOptions}); + if (profile.shortcuts) this.#shortcutManager?.setShortcutsOptions(profile.shortcuts); + if (profile.audioOptions) this.#audioManager?.setOptions(profile.audioOptions); + this.addInfoMessage("Profile loaded correctly"); console.log(`Profile for ${username} loaded correctly`); } else { @@ -335,6 +339,10 @@ export class OlympusApp { AdminPasswordChangedEvent.dispatch(newAdminPassword); } + getAdminPassword() { + return this.#adminPassword; + } + startServerMode() { //ConfigLoadedEvent.on((config) => { // this.getAudioManager().start(); diff --git a/frontend/react/src/types/types.ts b/frontend/react/src/types/types.ts index 97b714f4..d733f5d7 100644 --- a/frontend/react/src/types/types.ts +++ b/frontend/react/src/types/types.ts @@ -35,6 +35,11 @@ export type MapOptions = { showUnitCallsigns: boolean; }; +export type AudioOptions = { + input: string; + output: string; +} + export type MapHiddenTypes = { human: boolean; olympus: boolean; @@ -65,3 +70,22 @@ export type MGRS = { export type Coalition = "blue" | "neutral" | "red" | "all"; +export type SRSClientData = { + name: string; + unitID: number; + iff: { + control: number; + mode1: number; + mode2: number; + mode3: number; + mode4: boolean; + mic: number; + status: number; + }, + coalition: number; + radios: { + frequency: number; + modulation: number; + }[]; +}; + diff --git a/frontend/react/src/ui/panels/audiomenu.tsx b/frontend/react/src/ui/panels/audiomenu.tsx index f2e0d25e..41cdffca 100644 --- a/frontend/react/src/ui/panels/audiomenu.tsx +++ b/frontend/react/src/ui/panels/audiomenu.tsx @@ -9,8 +9,8 @@ import { AudioSink } from "../../audio/audiosink"; import { RadioSink } from "../../audio/radiosink"; import { UnitSinkPanel } from "./components/unitsinkpanel"; import { UnitSink } from "../../audio/unitsink"; -import { FaMinus, FaVolumeHigh } from "react-icons/fa6"; -import { getRandomColor } from "../../other/utils"; +import { FaMinus, FaPerson, FaVolumeHigh } from "react-icons/fa6"; +import { enumToCoalition, getRandomColor, zeroAppend } from "../../other/utils"; import { AudioManagerCoalitionChangedEvent, AudioManagerDevicesChangedEvent, @@ -21,11 +21,13 @@ import { AudioSourcesChangedEvent, CommandModeOptionsChangedEvent, ShortcutsChangedEvent, + SRSClientsChangedEvent, } from "../../events"; import { OlDropdown, OlDropdownItem } from "../components/oldropdown"; import { OlCoalitionToggle } from "../components/olcoalitiontoggle"; -import { Coalition } from "../../types/types"; -import { GAME_MASTER, NONE } from "../../constants/constants"; +import { Coalition, SRSClientData } from "../../types/types"; +import { AudioManagerState, GAME_MASTER, NONE } from "../../constants/constants"; +import { AudioManager } from "../../audio/audiomanager"; export function AudioMenu(props: { open: boolean; onClose: () => void; children?: JSX.Element | JSX.Element[] }) { const [devices, setDevices] = useState([] as MediaDeviceInfo[]); @@ -39,6 +41,8 @@ export function AudioMenu(props: { open: boolean; onClose: () => void; children? const [output, setOutput] = useState(undefined as undefined | MediaDeviceInfo); const [coalition, setCoalition] = useState("blue" as Coalition); const [commandMode, setCommandMode] = useState(NONE as string); + const [clientsData, setClientsData] = useState([] as SRSClientData[]); + const [connectedClientsOpen, setConnectedClientsOpen] = useState(false); /* Preallocate 128 references for the source and sink panels. If the number of references changes, React will give an error */ const sourceRefs = Array(128) @@ -76,7 +80,7 @@ export function AudioMenu(props: { open: boolean; onClose: () => void; children? }); AudioManagerStateChangedEvent.on(() => { - setAudioManagerEnabled(getApp().getAudioManager().isRunning()); + setAudioManagerEnabled(getApp().getAudioManager().isRunning() === AudioManagerState.RUNNING); }); ShortcutsChangedEvent.on((shortcuts) => setShortcuts(shortcuts)); @@ -86,6 +90,7 @@ export function AudioMenu(props: { open: boolean; onClose: () => void; children? AudioManagerOutputChangedEvent.on((output) => setOutput(output)); AudioManagerCoalitionChangedEvent.on((coalition) => setCoalition(coalition)); CommandModeOptionsChangedEvent.on((options) => setCommandMode(options.commandMode)); + SRSClientsChangedEvent.on((clientsData) => setClientsData(clientsData)); }, []); /* When the sinks or sources change, use the count state to force a rerender to update the connection lines */ @@ -131,62 +136,78 @@ export function AudioMenu(props: { open: boolean; onClose: () => void; children? >

Audio menu

- The audio menu allows you to add and manage audio sources, connect them to unit loudspeakers and radios, and to tune radio frequencies using the SRS integration. In other words, it allows you to communicate over SRS without the need of using the SRS client. + The audio menu allows you to add and manage audio sources, connect them to unit loudspeakers and radios, and to tune radio frequencies using the + SRS integration. In other words, it allows you to communicate over SRS without the need of using the SRS client.
- Because of the limitations of the browser, you need to enable the audio backend by clicking on the volume icon in the navigation header. Moreover, you need to allow the browser to access your microphone and speakers. It may take a couple of seconds for the audio backend to start. -
-
- For security reasons, the audio backend will only work if the page is served over HTTPS. + Because of the limitations of the browser, you need to enable the audio backend by clicking on the volume icon in the navigation header. Moreover, + you need to allow the browser to access your microphone and speakers. It may take a couple of seconds for the audio backend to start.
+
For security reasons, the audio backend will only work if the page is served over HTTPS.

Managing the audio backend

- You can select the input and output devices for the audio backend. The input device is the microphone that will be used to transmit your voice. The output device is the speaker that will be used to play the audio from the other players. + You can select the input and output devices for the audio backend. The input device is the microphone that will be used to transmit your voice. + The output device is the speaker that will be used to play the audio from the other players.
- You can also select the radio coalition. This will determine the default coalition for the radios you create. If you are in command mode, you can change the radio - coalition by clicking on the coalition toggle button. This will have no effect if radio coalition enforcing is not enabled in the SRS server. + You can also select the radio coalition. This will determine the default coalition for the radios you create. If you are in command mode, you can + change the radio coalition by clicking on the coalition toggle button. This will have no effect if radio coalition enforcing is not enabled in the + SRS server.

Creating audio sources

- You can add audio sources by clicking on the "Add audio source" button. By default, a microphone and a text to speech source are created, but you can add file sources as well, which allow to play audio files such as music, prerecorded messages, or background noise, such as gunfire or engine sounds. + You can add audio sources by clicking on the "Add audio source" button. By default, a microphone and a text to speech source are created, but you + can add file sources as well, which allow to play audio files such as music, prerecorded messages, or background noise, such as gunfire or engine + sounds.
- The text to speech generation works using the Google Cloud speech API and by default it works in English. For it to work, a valid Google Cloud API key must be installed on the Olympus backend server machine. See the backend documentation for more information. {/* TODO: put link here */} + The text to speech generation works using the Google Cloud speech API and by default it works in English. For it to work, a valid Google Cloud API + key must be installed on the Olympus backend server machine. See the backend documentation for more information. {/* TODO: put link here */}
- Text to speech and file sources can be set to operate in loop mode, which will make them repeat the audio indefinitely. This is useful for background noise or music. Moreover, you can set the volume of the audio sources. + Text to speech and file sources can be set to operate in loop mode, which will make them repeat the audio indefinitely. This is useful for + background noise or music. Moreover, you can set the volume of the audio sources.

Creating radios and loudspeakers

- By default, two radios are created, but you can add more by clicking on the "Add radio" button. Radios can be tuned to different frequencies, and they can be set to operate in AM or FM mode. You can also set the volume of the radios, and change the balance between the left and right channels. -
-
- When a new radio is created, it will NOT be in "listen" mode, so you will need to click on the "Tune to radio" button to start listening. + By default, two radios are created, but you can add more by clicking on the "Add radio" button. Radios can be tuned to different frequencies, and + they can be set to operate in AM or FM mode. You can also set the volume of the radios, and change the balance between the left and right + channels.
+
When a new radio is created, it will NOT be in "listen" mode, so you will need to click on the "Tune to radio" button to start listening.
You have three options to transmit on the radio:
-
  • By clicking on the "Talk on frequency" button on the radio panel. This will enable continuous transmission and will remain "on" until clicked again.
  • +
  • + By clicking on the "Talk on frequency" button on the radio panel. This will enable continuous transmission and will remain "on" until clicked + again. +
  • By clicking on the "Push to talk" button located over the mouse coordinates panel, on the bottom right corner of the map.
  • By using the "Push to talk" keyboard shortcuts, which can be edited in the options menu.
  • - Loudspeakers can be used to simulate environmental sounds, like 5MC calls on the carrier, or sirens. To create a loudspeaker, click on the unit that should broadcast the sound, and then click on the "Loudspeakers" button. PTT buttons for loudspeakers operate in the same way as radios. + Loudspeakers can be used to simulate environmental sounds, like 5MC calls on the carrier, or sirens. To create a loudspeaker, click on the unit + that should broadcast the sound, and then click on the "Loudspeakers" button. PTT buttons for loudspeakers operate in the same way as radios.
    - The loudspeakers system uses the SRS integration, so it will only work if other players' SRS clients are running and connected to the same server as Olympus. Moreover, the loudspeaker system operates using the INTERCOM radio in SRS, and for the time being it will only work for those radios that have the INTERCOM radio enabled (i.e. usually multicrew aircraft). + The loudspeakers system uses the SRS integration, so it will only work if other players' SRS clients are running and connected to the same server + as Olympus. Moreover, the loudspeaker system operates using the INTERCOM radio in SRS, and for the time being it will only work for those radios + that have the INTERCOM radio enabled (i.e. usually multicrew aircraft).

    Connecting sources and radios/loudspeakers

    - Each source can be connected to one or more radios or loudspeakers. To connect a source to a radio or loudspeaker, click on the "+" button on the right of the source panel, then click on the equivalent button on the desired radio/loudspeaker. To disconnect a source from a radio or loudspeaker, click on the "-" button next to the radio/loudspeaker. + Each source can be connected to one or more radios or loudspeakers. To connect a source to a radio or loudspeaker, click on the "+" button on the + right of the source panel, then click on the equivalent button on the desired radio/loudspeaker. To disconnect a source from a radio or + loudspeaker, click on the "-" button next to the radio/loudspeaker.
    - The connection lines will show the connections between the sources and the radios/loudspeakers. The color of the line is randomly generated and will be different for each source. + The connection lines will show the connections between the sources and the radios/loudspeakers. The color of the line is randomly generated and + will be different for each source.
    - By connecting multiple sources to the same radio/loudspeaker, you can create complex audio setups, like playing background music while transmitting on the radio. + By connecting multiple sources to the same radio/loudspeaker, you can create complex audio setups, like playing background music while + transmitting on the radio.
    ); @@ -224,17 +245,78 @@ export function AudioMenu(props: { open: boolean; onClose: () => void; children? )} + <> + {audioManagerEnabled && ( + <> +
    +
    setConnectedClientsOpen(!connectedClientsOpen)} + > + Connected clients
    {clientsData.length}
    + + + +
    -
    -
    - {audioManagerEnabled && ( - <> + {connectedClientsOpen && ( +
    + {clientsData.map((clientData, idx) => { + return ( +
    +
    {clientData.name}
    +
    getApp().getAudioManager().tuneNewRadio(clientData.radios[0].frequency, clientData.radios[0].modulation)} + > + {`${zeroAppend(clientData.radios[0].frequency / 1e6, 3, true, 3)} ${clientData.radios[0].modulation ? "FM" : "AM"}`}{" "} +
    +
    getApp().getAudioManager().tuneNewRadio(clientData.radios[1].frequency, clientData.radios[1].modulation)} + > + {`${zeroAppend(clientData.radios[1].frequency / 1e6, 3, true, 3)} ${clientData.radios[1].modulation ? "FM" : "AM"}`}{" "} +
    +
    + ); + })} +
    + )} +
    + +
    {commandMode === GAME_MASTER && (
    Radio coalition
    @@ -254,23 +336,18 @@ export function AudioMenu(props: { open: boolean; onClose: () => void; children? )} Input - {devices .filter((device) => device.kind === "audioinput") .map((device, idx) => { return ( getApp().getAudioManager().setInput(device)}> -
    {device.label}
    +
    {device.label}
    ); })}
    - - )} - {audioManagerEnabled && ( - <> - {" "} + Output {devices @@ -278,13 +355,24 @@ export function AudioMenu(props: { open: boolean; onClose: () => void; children? .map((device, idx) => { return ( getApp().getAudioManager().setOutput(device)}> -
    {device.label}
    +
    {device.label}
    ); })}
    - - )} +
    + + )} + + +
    +
    {audioManagerEnabled && Audio sources} <> {sources.map((source, idx) => { @@ -328,7 +416,7 @@ export function AudioMenu(props: { open: boolean; onClose: () => void; children? if (sink instanceof RadioSink) return ( { @@ -366,7 +454,7 @@ export function AudioMenu(props: { open: boolean; onClose: () => void; children? if (sink instanceof UnitSink) return ( void }, ref: ForwardedRef) => { const [expanded, setExpanded] = useState(false); @@ -24,21 +25,23 @@ export const RadioSinkPanel = forwardRef((props: { radio: RadioSink; shortcutKey data-[receiving='true']:border-white `} ref={ref} - > -
    { - setExpanded(!expanded); - }}> +
    { + setExpanded(!expanded); + }} + >
    { setExpanded(!expanded); }} > - @@ -56,14 +59,18 @@ export const RadioSinkPanel = forwardRef((props: { radio: RadioSink; shortcutKey )} - {!expanded && `${props.radio.getFrequency() / 1e6} MHz ${props.radio.getModulation() ? "FM" : "AM"}`} +
    + {!expanded && `${zeroAppend(props.radio.getFrequency() / 1e6, 3, true, 3)} ${props.radio.getModulation() ? "FM" : "AM"}`} + {props.radio.getConnectedClients()} +
    { + onClick={(ev) => { getApp().getAudioManager().removeSink(props.radio); + ev.stopPropagation(); }} > @@ -71,14 +78,18 @@ export const RadioSinkPanel = forwardRef((props: { radio: RadioSink; shortcutKey
    {expanded && ( <> - { - props.radio.setFrequency(value); - }} - /> +
    + { + props.radio.setFrequency(value); + }} + /> +
    -
    +
    + +
    { @@ -89,14 +100,14 @@ export const RadioSinkPanel = forwardRef((props: { radio: RadioSink; shortcutKey
    Left
    - { - props.radio.setPan((Number(ev.currentTarget.value) - 50) / 50); - }} - className="my-auto" - > -
    Right
    + { + props.radio.setPan((Number(ev.currentTarget.value) - 50) / 50); + }} + className="my-auto" + > +
    Right
    - diff --git a/frontend/react/src/ui/panels/components/unitsinkpanel.tsx b/frontend/react/src/ui/panels/components/unitsinkpanel.tsx index 0dcf60e7..19fe4be2 100644 --- a/frontend/react/src/ui/panels/components/unitsinkpanel.tsx +++ b/frontend/react/src/ui/panels/components/unitsinkpanel.tsx @@ -1,9 +1,9 @@ import React, { ForwardedRef, forwardRef, useEffect, useState } from "react"; -import { FaChevronUp, FaXmark } from "react-icons/fa6"; +import { FaChevronDown, FaChevronUp, FaVolumeHigh, FaXmark } from "react-icons/fa6"; import { getApp } from "../../../olympusapp"; import { UnitSink } from "../../../audio/unitsink"; import { OlStateButton } from "../../components/olstatebutton"; -import { faMicrophoneLines } from "@fortawesome/free-solid-svg-icons"; +import { faMicrophoneLines, faVolumeHigh } from "@fortawesome/free-solid-svg-icons"; import { OlRangeSlider } from "../../components/olrangeslider"; export const UnitSinkPanel = forwardRef((props: { sink: UnitSink; shortcutKeys: string[]; onExpanded: () => void }, ref: ForwardedRef) => { @@ -28,10 +28,10 @@ export const UnitSinkPanel = forwardRef((props: { sink: UnitSink; shortcutKeys: setExpanded(!expanded); }} > - @@ -66,7 +66,7 @@ export const UnitSinkPanel = forwardRef((props: { sink: UnitSink; shortcutKeys:
    {expanded && (
    - Near + - Far { props.sink.setPtt(!props.sink.getPtt()); }} - tooltip="Talk on frequency" + tooltip="Click to enable the loudspeaker" >
    )} diff --git a/frontend/react/src/ui/panels/controlspanel.tsx b/frontend/react/src/ui/panels/controlspanel.tsx index 65e8d53b..24f38b3b 100644 --- a/frontend/react/src/ui/panels/controlspanel.tsx +++ b/frontend/react/src/ui/panels/controlspanel.tsx @@ -91,14 +91,6 @@ export function ControlsPanel(props: {}) { target: faFighterJet, text: "Show unit actions", }); - //controls.push({ - // actions: shortcuts["toggleRelativePositions"]?.toActions(), - // text: "Activate group movement", - //}); - //controls.push({ - // actions: [...shortcuts["toggleRelativePositions"]?.toActions(), "Wheel"], - // text: "Rotate formation", - //}); } else if (appState === OlympusState.SPAWN) { controls = [ { diff --git a/frontend/react/src/ui/panels/header.tsx b/frontend/react/src/ui/panels/header.tsx index 95c07d18..b1e62431 100644 --- a/frontend/react/src/ui/panels/header.tsx +++ b/frontend/react/src/ui/panels/header.tsx @@ -17,6 +17,7 @@ import { } from "../components/olicons"; import { FaChevronLeft, FaChevronRight, FaFloppyDisk } from "react-icons/fa6"; import { + AudioManagerStateChangedEvent, CommandModeOptionsChangedEvent, ConfigLoadedEvent, EnabledCommandModesChangedEvent, @@ -27,6 +28,7 @@ import { SessionDataSavedEvent, } from "../../events"; import { + AudioManagerState, BLUE_COMMANDER, COMMAND_MODE_OPTIONS_DEFAULTS, GAME_MASTER, @@ -39,6 +41,7 @@ import { import { OlympusConfig } from "../../interfaces"; import { FaCheck, FaRedo, FaSpinner } from "react-icons/fa"; import { OlExpandingTooltip } from "../components/olexpandingtooltip"; +import { stat } from "fs"; export function Header() { const [mapHiddenTypes, setMapHiddenTypes] = useState(MAP_HIDDEN_TYPES_DEFAULTS); @@ -47,7 +50,7 @@ export function Header() { const [mapSources, setMapSources] = useState([] as string[]); const [scrolledLeft, setScrolledLeft] = useState(true); const [scrolledRight, setScrolledRight] = useState(false); - const [audioEnabled, setAudioEnabled] = useState(false); + const [audioState, setAudioState] = useState(AudioManagerState.STOPPED); const [commandModeOptions, setCommandModeOptions] = useState(COMMAND_MODE_OPTIONS_DEFAULTS); const [savingSessionData, setSavingSessionData] = useState(false); const [latestVersion, setLatestVersion] = useState(""); @@ -77,6 +80,7 @@ export function Header() { SessionDataChangedEvent.on(() => setSavingSessionData(true)); SessionDataSavedEvent.on(() => setSavingSessionData(false)); EnabledCommandModesChangedEvent.on((enabledCommandModes) => setEnabledCommandModes(enabledCommandModes)); + AudioManagerStateChangedEvent.on((state) => setAudioState(state as AudioManagerState)); /* Check if we are running the latest version */ const request = new Request("https://raw.githubusercontent.com/Pax1601/DCSOlympus/main/version.json"); @@ -246,9 +250,15 @@ export function Header() { > Game Master {enabledCommandModes.length > 0 && ( - <>{loadingNewCommandMode ? : } + <> + {loadingNewCommandMode ? ( + + ) : ( + + )} + )}
    )} @@ -271,9 +281,15 @@ export function Header() { > BLUE Commander {enabledCommandModes.length > 0 && ( - <>{loadingNewCommandMode ? : } + <> + {loadingNewCommandMode ? ( + + ) : ( + + )} + )}
    )} @@ -296,9 +312,15 @@ export function Header() { > RED Commander {enabledCommandModes.length > 0 && ( - <>{loadingNewCommandMode ? : } + <> + {loadingNewCommandMode ? ( + + ) : ( + + )} + )}
    )} @@ -325,11 +347,13 @@ export function Header() { )} /> { - audioEnabled ? getApp().getAudioManager().stop() : getApp().getAudioManager().start(); - setAudioEnabled(!audioEnabled); + audioState === AudioManagerState.RUNNING ? getApp().getAudioManager().stop() : getApp().getAudioManager().start(); }} + className={audioState === AudioManagerState.ERROR ? ` + animate-pulse !border-red-500 !text-red-500 + ` : ""} tooltip={() => ( { InfoPopupEvent.on((messages) => setMessages([...messages])); - AppStateChangedEvent.on((state, subState) => setAppState(state)); - ContextActionChangedEvent.on((contextAction) => setContextAction(contextAction)); }, []); return ( diff --git a/frontend/react/src/ui/panels/maptoolbar.tsx b/frontend/react/src/ui/panels/maptoolbar.tsx index b71417a2..8643c564 100644 --- a/frontend/react/src/ui/panels/maptoolbar.tsx +++ b/frontend/react/src/ui/panels/maptoolbar.tsx @@ -18,7 +18,7 @@ import { SelectionEnabledChangedEvent, ShortcutsChangedEvent, } from "../../events"; -import { faCopy, faEraser, faObjectGroup, faPaste, faTape } from "@fortawesome/free-solid-svg-icons"; +import { faCopy, faEraser, faMinus, faObjectGroup, faPaste, faPlus, faTape } from "@fortawesome/free-solid-svg-icons"; import { Shortcut } from "../../shortcut/shortcut"; import { ShortcutOptions, UnitData } from "../../interfaces"; import { Unit } from "../../unit/unit"; @@ -146,12 +146,36 @@ export function MapToolBar(props: {}) { `} /> )} -
    onScroll(ev.target)} ref={scrollRef}> +
    onScroll(ev.target)} + ref={scrollRef} + > <>
    +
    Zoom map in
    } + tooltipPosition="side" + onClick={() => { + getApp().getMap().zoomIn(); + }} + /> +
    Zoom map out
    } + tooltipPosition="side" + onClick={() => { + getApp().getMap().zoomOut(); + }} + /> )}
    - ( -
    - {shortcutCombination(shortcuts["measure"]?.getOptions())} -
    Enter measure mode
    -
    - )} - tooltipPosition="side" - onClick={() => { - getApp().setState(appState === OlympusState.MEASURE? OlympusState.IDLE : OlympusState.MEASURE); - }} - /> -
    -
    - ( -
    - {shortcutCombination(shortcuts["clearMeasures"]?.getOptions())} -
    Clear all measures
    -
    - )} - tooltipPosition="side" - onClick={() => { - getApp().getMap().clearMeasures(); - }} - /> -
    + ( +
    + {shortcutCombination(shortcuts["measure"]?.getOptions())} +
    Enter measure mode
    +
    + )} + tooltipPosition="side" + onClick={() => { + getApp().setState(appState === OlympusState.MEASURE ? OlympusState.IDLE : OlympusState.MEASURE); + }} + /> +
    +
    + ( +
    + {shortcutCombination(shortcuts["clearMeasures"]?.getOptions())} +
    Clear all measures
    +
    + )} + tooltipPosition="side" + onClick={() => { + getApp().getMap().clearMeasures(); + }} + /> +
    {reorderedActions.map((contextActionIt: ContextAction) => { diff --git a/frontend/react/src/ui/panels/minimappanel.tsx b/frontend/react/src/ui/panels/minimappanel.tsx index 6a97d8a3..21423b73 100644 --- a/frontend/react/src/ui/panels/minimappanel.tsx +++ b/frontend/react/src/ui/panels/minimappanel.tsx @@ -57,7 +57,7 @@ export function MiniMapPanel(props: {}) { absolute right-[10px] ${mapOptions.showMinimap ? `bottom-[188px]` : `bottom-[20px]`} flex w-[288px] cursor-pointer flex-col items-center justify-between - gap-2 text-sm backdrop-blur-lg + gap-2 text-sm `} > @@ -65,7 +65,7 @@ export function MiniMapPanel(props: {}) {
    {audioSinks.length > 0 && (
    - {audioSinks.filter((audioSinks) => audioSinks instanceof RadioSink).length > 0 && audioSinks .filter((audioSinks) => audioSinks instanceof RadioSink) @@ -43,9 +40,12 @@ export function RadiosSummaryPanel(props: {}) { buttonColor={radioSink.getReceiving() ? colors.WHITE : undefined} className="min-h-12 min-w-12" > -
    {idx + 1}
    + + {" "} +
    {idx + 1}
    +
    ); })} @@ -67,9 +67,12 @@ export function RadiosSummaryPanel(props: {}) { tooltip="Click to talk" className="min-h-12 min-w-12" > -
    {idx + 1}
    + + {" "} +
    {idx + 1}
    +
    ); })} diff --git a/frontend/react/src/ui/panels/unitcontrolmenu.tsx b/frontend/react/src/ui/panels/unitcontrolmenu.tsx index 4a3de7ce..be496653 100644 --- a/frontend/react/src/ui/panels/unitcontrolmenu.tsx +++ b/frontend/react/src/ui/panels/unitcontrolmenu.tsx @@ -7,6 +7,7 @@ import { getApp } from "../../olympusapp"; import { OlButtonGroup, OlButtonGroupItem } from "../components/olbuttongroup"; import { OlCheckbox } from "../components/olcheckbox"; import { + AudioManagerState, ROEs, UnitState, altitudeIncrements, @@ -95,7 +96,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) { } const [selectedUnits, setSelectedUnits] = useState([] as Unit[]); - const [audioManagerState, setAudioManagerState] = useState(false); + const [audioManagerRunning, setAudioManagerRunning] = useState(false); const [selectedUnitsData, setSelectedUnitsData] = useState(initializeUnitsData); const [forcedUnitsData, setForcedUnitsData] = useState(initializeUnitsData); const [selectionFilter, setSelectionFilter] = useState({ @@ -152,7 +153,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) { useEffect(() => { SelectedUnitsChangedEvent.on((units) => setSelectedUnits(units)); SelectionClearedEvent.on(() => setSelectedUnits([])); - AudioManagerStateChangedEvent.on((state) => setAudioManagerState(state)); + AudioManagerStateChangedEvent.on((state) => setAudioManagerRunning(state === AudioManagerState.RUNNING)); UnitsUpdatedEvent.on((units) => units.find((unit) => unit.getSelected()) && setLastUpdateTime(Date.now())); }, []); @@ -210,8 +211,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) { if (newDatum === forcedUnitsData[key]) { anyForcedDataUpdated = true; forcedUnitsData[key] = undefined; - } - else updatedData[key] = forcedUnitsData[key]; + } else updatedData[key] = forcedUnitsData[key]; } else updatedData[key] = newDatum; }); setSelectedUnitsData(updatedData as typeof selectedUnitsData); @@ -420,9 +420,12 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) { return ( -
    {entry[1][1] as string}
    + {" "} +
    + {entry[1][1] as string} +
    {["blue", "neutral", "red"].map((coalition) => { return ( @@ -797,53 +800,65 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
    {" "} - Hold fire: The unit will not shoot in - any circumstance + {" "} + Hold fire: The unit will not shoot in any circumstance
    {" "} - Return fire: The unit will not fire - unless fired upon + {" "} + Return fire: The unit will not fire unless fired upon
    {" "} - {" "} + {" "}
    {" "} - Fire on target: The unit will not fire unless fired upon

    or

    ordered to do so{" "} + Fire on target: The unit will not fire unless fired upon{" "} +

    + or +

    {" "} + ordered to do so{" "}
    {" "} - Free: The unit will fire at any - detected enemy in range + {" "} + Free: The unit will fire at any detected enemy in range
    - +
    Currently, DCS blue and red ground units do not respect{" "} - and{" "} - rules of engagement, so be careful, they - may start shooting when you don't want them to. Use neutral units for finer control. + {" "} + and{" "} + {" "} + rules of engagement, so be careful, they may start shooting when you don't want them to. Use neutral units for finer + control.
    @@ -897,35 +912,25 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
    {" "} - {" "} - Green: The unit will not engage with its sensors in any circumstances. The unit will be able to move. + `} /> Green: The unit will not engage + with its sensors in any circumstances. The unit will be able to move.
    {" "} - {" "} + `} />{" "}
    Auto: The unit will use its sensors to engage based on its ROE.
    {" "} - {" "} - Red: The unit will be actively searching for target with its sensors. For some units, this will deploy the radar and make - the unit not able to move. + `} /> Red: The unit will be actively + searching for target with its sensors. For some units, this will deploy the radar and make the unit not able to move.
    @@ -982,31 +987,35 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
    {" "} - No reaction: The unit will not - react in any circumstance + {" "} + No reaction: The unit will not react in any circumstance
    {" "} - Passive: The unit will use - counter-measures, but will not alter its course + {" "} + Passive: The unit will use counter-measures, but will not alter its course
    {" "} - Manouevre: The unit will try - to evade the threat using manoeuvres, but no counter-measures + {" "} + Manouevre: The unit will try to evade the threat using manoeuvres, but no counter-measures
    {" "} - Full evasion: the unit will try - to evade the threat both manoeuvering and using counter-measures + {" "} + Full evasion: the unit will try to evade the threat both manoeuvering and using counter-measures
    @@ -1057,31 +1066,35 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
    {" "} - Radio silence: No radar or - ECM will be used + {" "} + Radio silence: No radar or ECM will be used
    {" "} - Defensive: The unit will turn - radar and ECM on only when threatened + {" "} + Defensive: The unit will turn radar and ECM on only when threatened
    {" "} - Attack: The unit will use - radar and ECM when engaging other units + {" "} + Attack: The unit will use radar and ECM when engaging other units
    {" "} - Free: the unit will use the - radar and ECM all the time + {" "} + Free: the unit will use the radar and ECM all the time
    @@ -1299,9 +1312,9 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) { >
    - +
    Currently, DCS blue and red ground units do not respect their rules of engagement, so be careful, they may start shooting when @@ -1401,7 +1414,9 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) { {/* ============== Miss on purpose toggle END ============== */}
    {/* ============== Shots scatter START ============== */} -
    +
    void }) {
    {/* ============== Operate as toggle START ============== */} {selectedUnits.every((unit) => unit.getCoalition() === "neutral") && ( -
    +
    void }) { )} {/* ============== Audio sink toggle START ============== */} -
    - - Loudspeakers - - {audioManagerState ? ( - { - selectedUnits.forEach((unit) => { - if (!selectedUnitsData.isAudioSink) { - getApp()?.getAudioManager().addUnitSink(unit); - setForcedUnitsData({ - ...forcedUnitsData, - isAudioSink: true, - }); - } else { - let sink = getApp() - ?.getAudioManager() - .getSinks() - .find((sink) => { - return sink instanceof UnitSink && sink.getUnit() === unit; + {selectedUnits.length === 1 && ( +
    + + Loudspeakers + + {audioManagerRunning ? ( + { + selectedUnits.forEach((unit) => { + if (!selectedUnitsData.isAudioSink) { + getApp()?.getAudioManager().addUnitSink(unit); + setForcedUnitsData({ + ...forcedUnitsData, + isAudioSink: true, }); - if (sink !== undefined) getApp()?.getAudioManager().removeSink(sink); - - setForcedUnitsData({ - ...forcedUnitsData, - isAudioSink: false, - }); - } - }); - }} - tooltip={() => ( - - )} - tooltipRelativeToParent={true} - tooltipPosition="above" - /> - ) : ( -
    - Enable audio with{" "} - - - {" "} - first -
    - )} -
    + } else { + let sink = getApp() + ?.getAudioManager() + .getSinks() + .find((sink) => { + return sink instanceof UnitSink && sink.getUnit() === unit; + }); + if (sink !== undefined) getApp()?.getAudioManager().removeSink(sink); + setForcedUnitsData({ + ...forcedUnitsData, + isAudioSink: false, + }); + } + }); + }} + tooltip={() => ( + + )} + tooltipRelativeToParent={true} + tooltipPosition="above" + /> + ) : ( +
    + Enable audio with{" "} + + + {" "} + first +
    + )} +
    + )} {/* ============== Audio sink toggle END ============== */}
    )} @@ -1973,9 +1989,10 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) { value={activeRadioSettings ? activeRadioSettings.TACAN.channel : 1} > - + { @@ -2208,9 +2225,11 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) { className={` flex content-center gap-2 rounded-full ${selectedUnits[0].getFuel() > 40 && `bg-green-700`} - ${selectedUnits[0].getFuel() > 10 && selectedUnits[0].getFuel() <= 40 && ` - bg-yellow-700 - `} + ${ + selectedUnits[0].getFuel() > 10 && + selectedUnits[0].getFuel() <= 40 && + `bg-yellow-700` + } ${selectedUnits[0].getFuel() <= 10 && `bg-red-700`} px-2 py-1 text-sm font-bold text-white `} @@ -2228,10 +2247,9 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) { )}
    - +
    {Math.round(mToFt(selectedUnits[0].getPosition().alt ?? 0))} ft
    diff --git a/frontend/react/src/ui/ui.tsx b/frontend/react/src/ui/ui.tsx index ab159ca5..65c33705 100644 --- a/frontend/react/src/ui/ui.tsx +++ b/frontend/react/src/ui/ui.tsx @@ -26,8 +26,6 @@ import { GameMasterMenu } from "./panels/gamemastermenu"; import { InfoBar } from "./panels/infobar"; import { HotGroupBar } from "./panels/hotgroupsbar"; import { SpawnContextMenu } from "./contextmenus/spawncontextmenu"; -import { CoordinatesPanel } from "./panels/coordinatespanel"; -import { RadiosSummaryPanel } from "./panels/radiossummarypanel"; import { ServerOverlay } from "./serveroverlay"; import { ImportExportModal } from "./modals/importexportmodal"; import { WarningModal } from "./modals/warningmodal"; diff --git a/frontend/server/src/audio/audiopacket.ts b/frontend/server/src/audio/audiopacket.ts index 4c083b96..d2ac1311 100644 --- a/frontend/server/src/audio/audiopacket.ts +++ b/frontend/server/src/audio/audiopacket.ts @@ -7,7 +7,7 @@ var packetID = 0; export enum MessageType { audio, settings, - unitIDs + clientsData } export class AudioPacket { diff --git a/frontend/server/src/audio/srshandler.ts b/frontend/server/src/audio/srshandler.ts index 14ee042c..d42dd8e6 100644 --- a/frontend/server/src/audio/srshandler.ts +++ b/frontend/server/src/audio/srshandler.ts @@ -58,22 +58,31 @@ export class SRSHandler { if (error) console.log(`Error pinging SRS server on UDP: ${error}`); }); - if (this.tcp.readyState == "open") - this.tcp.write(`${JSON.stringify(SYNC)}\n`); + if (this.tcp.readyState == "open") this.tcp.write(`${JSON.stringify(SYNC)}\n`); else clearInterval(this.syncInterval); let unitsBuffer = Buffer.from( JSON.stringify({ - unitIDs: this.clients.map((client) => { - return client.RadioInfo.unitId; + clientsData: this.clients.map((client) => { + return { + name: client.Name, + unitID: client.RadioInfo.unitId, + iff: client.RadioInfo.iff, + coalition: client.Coalition, + radios: client.RadioInfo.radios.map((radio) => { + return { + frequency: radio.freq, + modulation: radio.modulation, + + }; + }), + }; }), }), "utf-8" ); - this.ws.send( - ([] as number[]).concat([MessageType.unitIDs], [...unitsBuffer]) - ); + this.ws.send(([] as number[]).concat([MessageType.clientsData], [...unitsBuffer])); }, 1000); }); @@ -96,10 +105,7 @@ export class SRSHandler { }); this.udp.on("message", (message, remote) => { - if (this.ws && message.length > 22) - this.ws.send( - ([] as number[]).concat([MessageType.audio], [...message]) - ); + if (this.ws && message.length > 22) this.ws.send(([] as number[]).concat([MessageType.audio], [...message])); }); } @@ -115,6 +121,14 @@ export class SRSHandler { let message = JSON.parse(data.slice(1)); this.data.ClientGuid = message.guid; this.data.Coalition = message.coalition; + + /* First reset all the radios to default values */ + this.data.RadioInfo.radios.forEach((radio) => { + radio.freq = defaultSRSData.RadioInfo.radios[0].freq; + radio.modulation = defaultSRSData.RadioInfo.radios[0].modulation; + }); + + /* Then update the radios with the new settings */ message.settings.forEach((setting, idx) => { this.data.RadioInfo.radios[idx].freq = setting.frequency; this.data.RadioInfo.radios[idx].modulation = setting.modulation;