diff --git a/databases/units/navyunitdatabase.json b/databases/units/navyunitdatabase.json index 0c90d0ad..1a945f6c 100644 --- a/databases/units/navyunitdatabase.json +++ b/databases/units/navyunitdatabase.json @@ -15,7 +15,9 @@ "description": "", "abilities": "", "canTargetPoint": false, - "canRearm": false + "canRearm": false, + "carrierFilename": "nimitz.svg", + "length": 300 }, "CVN_72": { "name": "CVN_72", @@ -33,7 +35,9 @@ "description": "", "abilities": "", "canTargetPoint": false, - "canRearm": false + "canRearm": false, + "carrierFilename": "nimitz.svg", + "length": 300 }, "CVN_73": { "name": "CVN_73", @@ -51,7 +55,9 @@ "description": "", "abilities": "", "canTargetPoint": false, - "canRearm": false + "canRearm": false, + "carrierFilename": "nimitz.svg", + "length": 300 }, "CVN_75": { "name": "CVN_75", @@ -91,7 +97,9 @@ "description": "", "abilities": "", "canTargetPoint": false, - "canRearm": false + "canRearm": false, + "carrierFilename": "nimitz.svg", + "length": 300 }, "CV_1143_5": { "name": "CV_1143_5", diff --git a/frontend/react/public/images/carriers/633052.jpg b/frontend/react/public/images/carriers/633052.jpg new file mode 100644 index 00000000..c081bc38 Binary files /dev/null and b/frontend/react/public/images/carriers/633052.jpg differ diff --git a/frontend/react/public/images/carriers/kuznetsov.png b/frontend/react/public/images/carriers/kuznetsov.png new file mode 100644 index 00000000..ee23640c Binary files /dev/null and b/frontend/react/public/images/carriers/kuznetsov.png differ diff --git a/frontend/react/public/images/carriers/nimitz.png b/frontend/react/public/images/carriers/nimitz.png new file mode 100644 index 00000000..6cfcf7b6 Binary files /dev/null and b/frontend/react/public/images/carriers/nimitz.png differ diff --git a/frontend/react/src/audio/audiodopplerprocessor.js b/frontend/react/src/audio/audiodopplerprocessor.js deleted file mode 100644 index 5c53086e..00000000 --- a/frontend/react/src/audio/audiodopplerprocessor.js +++ /dev/null @@ -1,14 +0,0 @@ -class AudioDopplerProcessor extends AudioWorkletProcessor { - process(inputs, outputs, parameters) { - const output = outputs[0]; - output.forEach((channel) => { - for (let i = 0; i < channel.length; i++) { - channel[i] = Math.random() * 2 - 1; - } - }); - return true; - } - } - - registerProcessor("audio-doppler-processor", AudioDopplerProcessor); - \ No newline at end of file diff --git a/frontend/react/src/audio/audiolibrary.js b/frontend/react/src/audio/audiolibrary.js index 52770543..f5e31c89 100644 --- a/frontend/react/src/audio/audiolibrary.js +++ b/frontend/react/src/audio/audiolibrary.js @@ -1,4 +1,6 @@ // TODO Convert to typescript +// Audio library I shamelessly copied from the web + // SAFARI Polyfills if (!window.AudioBuffer.prototype.copyToChannel) { window.AudioBuffer.prototype.copyToChannel = function copyToChannel(buffer, channel) { diff --git a/frontend/react/src/audio/audiomanager.ts b/frontend/react/src/audio/audiomanager.ts index 60669849..e8834e88 100644 --- a/frontend/react/src/audio/audiomanager.ts +++ b/frontend/react/src/audio/audiomanager.ts @@ -24,6 +24,9 @@ export class AudioManager { /* List of all possible audio sources (microphone, file stream etc...) */ #sources: AudioSource[] = []; + /* 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; #address: string = "localhost"; #port: number = 4000; #socket: WebSocket | null = null; @@ -44,6 +47,7 @@ export class AudioManager { } start() { + this.#running = true; this.#audioContext = new AudioContext({ sampleRate: 16000 }); this.#playbackPipeline = new PlaybackPipeline(); @@ -77,11 +81,12 @@ export class AudioManager { /* Extract the frequency value and play it on the speakers if we are listening to it*/ audioPacket.getFrequencies().forEach((frequencyInfo) => { if (sink.getFrequency() === frequencyInfo.frequency && sink.getModulation() === frequencyInfo.modulation) { - this.#playbackPipeline.play(audioPacket.getAudioData().buffer); + this.#playbackPipeline.playBuffer(audioPacket.getAudioData().buffer); } }); } else { this.#SRSClientUnitIDs = JSON.parse(new TextDecoder().decode(packetUint8Array.slice(1))).unitIDs; + document.dispatchEvent(new CustomEvent("SRSClientsUpdated")); } } }); @@ -99,18 +104,25 @@ export class AudioManager { /* Add two default radios */ this.addRadio(); this.addRadio(); + }); + document.dispatchEvent(new CustomEvent("audioManagerStateChanged")); } stop() { + this.#running = false; this.#sources.forEach((source) => { source.disconnect(); }); + this.#sinks.forEach((sink) => { + sink.disconnect(); + }); this.#sources = []; this.#sinks = []; document.dispatchEvent(new CustomEvent("audioSourcesUpdated")); document.dispatchEvent(new CustomEvent("audioSinksUpdated")); + document.dispatchEvent(new CustomEvent("audioManagerStateChanged")); } setAddress(address) { @@ -122,22 +134,47 @@ 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); - newSource.connect(this.#sinks[0]); + document.dispatchEvent(new CustomEvent("audioSourcesUpdated")); + } + + getSources() { + return this.#sources; + } + + 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); document.dispatchEvent(new CustomEvent("audioSourcesUpdated")); } 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)); document.dispatchEvent(new CustomEvent("audioSinksUpdated")); } - getSinks() { - return this.#sinks; - } - addRadio() { + console.log("Adding new radio"); + if (!this.#running) { + console.log("Audio manager not started, aborting..."); + return; + } const newRadio = new RadioSink(); this.#sinks.push(newRadio); newRadio.setName(`Radio ${this.#sinks.length}`); @@ -145,7 +182,16 @@ export class AudioManager { document.dispatchEvent(new CustomEvent("audioSinksUpdated")); } + getSinks() { + return this.#sinks; + } + 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; @@ -155,16 +201,6 @@ export class AudioManager { document.dispatchEvent(new CustomEvent("audioSinksUpdated")); } - removeSource(source) { - source.disconnect(); - this.#sources = this.#sources.filter((v) => v != source); - document.dispatchEvent(new CustomEvent("audioSourcesUpdated")); - } - - getSources() { - return this.#sources; - } - getGuid() { return this.#guid; } @@ -181,6 +217,10 @@ export class AudioManager { return this.#SRSClientUnitIDs; } + isRunning() { + return this.#running; + } + #syncRadioSettings() { let message = { type: "Settings update", diff --git a/frontend/react/src/audio/audiopacket.ts b/frontend/react/src/audio/audiopacket.ts index b20b134c..a92042d9 100644 --- a/frontend/react/src/audio/audiopacket.ts +++ b/frontend/react/src/audio/audiopacket.ts @@ -1,3 +1,4 @@ +// TODO This code is in common with the backend, would be nice to share it */ import { byteArrayToDouble, byteArrayToInteger, doubleToByteArray, integerToByteArray } from "../other/utils"; import { Buffer } from "buffer"; @@ -20,11 +21,6 @@ export class AudioPacket { #unitID: number = 0; #hops: number = 0; - /* Out of standard data (this is not compliant with SRS standard, used for external audio effects) */ - #latitude: number | null = null; - #longitude: number | null = null; - #altitude: number | null = null; - /* Usually internally set only */ #packetID: number | null = null; @@ -138,18 +134,6 @@ export class AudioPacket { [...Buffer.from(this.#clientGUID, "utf-8")] ); - if ( - this.#latitude !== undefined && - this.#longitude !== undefined && - this.#altitude !== undefined - ) { - encodedData.concat( - [...doubleToByteArray(this.#latitude)], - [...doubleToByteArray(this.#longitude)], - [...doubleToByteArray(this.#altitude)] - ); - } - // Set the lengths of the parts let encPacketLen = integerToByteArray(encodedData.length, 2); encodedData[0] = encPacketLen[0]; @@ -223,28 +207,4 @@ export class AudioPacket { getHops() { return this.#hops; } - - setLatitude(latitude: number) { - this.#latitude = latitude; - } - - getLatitude() { - return this.#latitude; - } - - setLongitude(longitude: number) { - this.#longitude = longitude; - } - - getLongitude() { - return this.#longitude; - } - - setAltitude(altitude: number) { - this.#altitude = altitude; - } - - getAltitude() { - return this.#altitude; - } } diff --git a/frontend/react/src/audio/audiosink.ts b/frontend/react/src/audio/audiosink.ts index 84ffea80..af90064a 100644 --- a/frontend/react/src/audio/audiosink.ts +++ b/frontend/react/src/audio/audiosink.ts @@ -1,6 +1,7 @@ import { getApp } from "../olympusapp"; -export abstract class AudioSink { +/* Base audio sink class */ +export class AudioSink { #name: string; #gainNode: GainNode; diff --git a/frontend/react/src/audio/audiosource.ts b/frontend/react/src/audio/audiosource.ts index f281f02a..e852f07a 100644 --- a/frontend/react/src/audio/audiosource.ts +++ b/frontend/react/src/audio/audiosource.ts @@ -2,6 +2,7 @@ import { getApp } from "../olympusapp"; import { AudioSink } from "./audiosink"; import { WebAudioPeakMeter } from "web-audio-peak-meter"; +/* Base abstract audio source class */ export abstract class AudioSource { #connectedTo: AudioSink[] = []; #name = ""; @@ -11,7 +12,9 @@ export abstract class AudioSource { constructor() { this.#gainNode = getApp().getAudioManager().getAudioContext().createGain(); - this.#meter = new WebAudioPeakMeter(this.#gainNode, document.createElement('div')); + + /* This library requires a div element to initialize the object. Create a fake element, we will read the data and render it ourselves. */ + this.#meter = new WebAudioPeakMeter(this.#gainNode, document.createElement("div")); } connect(sink: AudioSink) { @@ -61,5 +64,6 @@ export abstract class AudioSource { return this.#gainNode; } + /* Play method must be implemented by child classes */ abstract play(): void; } diff --git a/frontend/react/src/audio/audiounitpipeline.ts b/frontend/react/src/audio/audiounitpipeline.ts index f62aba94..f43b20fd 100644 --- a/frontend/react/src/audio/audiounitpipeline.ts +++ b/frontend/react/src/audio/audiounitpipeline.ts @@ -4,6 +4,7 @@ import { Filter, Noise } from "./audiolibrary"; import { AudioPacket } from "./audiopacket"; let packetID = 0; +const MAX_DISTANCE = 1852; // Ignore clients that are further away than 1NM, to save performance. export class AudioUnitPipeline { #inputNode: GainNode; @@ -13,16 +14,15 @@ export class AudioUnitPipeline { #destinationNode: MediaStreamAudioDestinationNode; #audioTrackProcessor: any; #encoder: AudioEncoder; - #distance: number = 0; - #convolver: ConvolverNode; - #delay: DelayNode; - #multitap: DelayNode[]; - #multitapGain: GainNode; - #wet: GainNode; + #convolverNode: ConvolverNode; + #preDelayNode: DelayNode; + #multitapNodes: DelayNode[]; + #multitapGainNode: GainNode; + #wetGainNode: GainNode; #tailOsc: Noise; - #dataBuffer: number[] = []; + #distance: number = 0; constructor(sourceUnit: Unit, unitID: number, inputNode: GainNode) { this.#sourceUnit = sourceUnit; @@ -67,33 +67,42 @@ export class AudioUnitPipeline { /* Create the pipeline */ this.#inputNode = inputNode; - this.#inputNode.connect(this.#gainNode); this.#setupEffects(); /* Create the interval task to update the data */ setInterval(() => { + /* Get the destination unit and compute the distance to it */ let destinationUnit = getApp().getUnitsManager().getUnitByID(this.#unitID); if (destinationUnit) { let distance = destinationUnit?.getPosition().distanceTo(this.#sourceUnit.getPosition()); + + /* The units positions are updated at a low frequency. Filter the distance to avoid sudden volume jumps */ this.#distance = 0.9 * this.#distance + 0.1 * distance; - let newGain = 1.0 - Math.pow(this.#distance / 1000, 0.5); // Arbitrary + /* Don't bother updating parameters if the client is too far away */ + if (this.#distance < MAX_DISTANCE) { + /* Compute a new gain decreasing with distance. */ + let newGain = 1.0 - Math.pow(this.#distance / 1000, 0.5); // Arbitrary - this.#gainNode.gain.setValueAtTime(newGain, getApp().getAudioManager().getAudioContext().currentTime); - this.#multitapGain.gain.setValueAtTime(newGain / 10, getApp().getAudioManager().getAudioContext().currentTime); + /* Set the values of the main gain node and the multitap gain node, used for reverb effect */ + this.#gainNode.gain.setValueAtTime(newGain, getApp().getAudioManager().getAudioContext().currentTime); + this.#multitapGainNode.gain.setValueAtTime(newGain / 10, getApp().getAudioManager().getAudioContext().currentTime); - let reverbTime = this.#distance / 1000 / 2; //Arbitrary - let preDelay = this.#distance / 1000; // Arbitrary - this.#delay.delayTime.setValueAtTime(preDelay, getApp().getAudioManager().getAudioContext().currentTime); - this.#multitap.forEach((t, i) => { - t.delayTime.setValueAtTime(0.001 + i * (preDelay / 2), getApp().getAudioManager().getAudioContext().currentTime); - }); - this.#tailOsc.release = reverbTime / 3; + /* Increase reverb and predelay with distance */ + let reverbTime = this.#distance / 1000 / 4; //Arbitrary + let preDelay = this.#distance / 1000 / 2; // Arbitrary + this.#preDelayNode.delayTime.setValueAtTime(preDelay, getApp().getAudioManager().getAudioContext().currentTime); + this.#multitapNodes.forEach((t, i) => { + t.delayTime.setValueAtTime(0.001 + i * (preDelay / 2), getApp().getAudioManager().getAudioContext().currentTime); + }); + this.#tailOsc.release = reverbTime / 3; + } } }, 100); } handleEncodedData(encodedAudioChunk, unitID) { + /* Encode the data in SRS format and send it to the backend */ let arrayBuffer = new ArrayBuffer(encodedAudioChunk.byteLength); encodedAudioChunk.copyTo(arrayBuffer); @@ -118,67 +127,67 @@ export class AudioUnitPipeline { handleRawData(audioData) { /* Ignore players that are too far away */ - if (this.#distance < 1000) { + if (this.#distance < MAX_DISTANCE) { this.#encoder.encode(audioData); - audioData.close(); } } #setupEffects() { - let reverbTime = 0.1; //Arbitrary - - this.#convolver = getApp().getAudioManager().getAudioContext().createConvolver(); - this.#delay = getApp().getAudioManager().getAudioContext().createDelay(1); - - this.#multitap = []; + /* Create the nodes necessary for the pipeline */ + this.#convolverNode = getApp().getAudioManager().getAudioContext().createConvolver(); + this.#preDelayNode = getApp().getAudioManager().getAudioContext().createDelay(1); + this.#multitapGainNode = getApp().getAudioManager().getAudioContext().createGain(); + this.#wetGainNode = getApp().getAudioManager().getAudioContext().createGain(); + this.#multitapNodes = []; for (let i = 2; i > 0; i--) { - this.#multitap.push(getApp().getAudioManager().getAudioContext().createDelay(1)); + this.#multitapNodes.push(getApp().getAudioManager().getAudioContext().createDelay(1)); } - this.#multitap.map((t, i) => { - if (this.#multitap[i + 1]) { - t.connect(this.#multitap[i + 1]); + + /* Connect the nodes as follows + /------> pre delay -> convolver ------\ + input -> main gain -> wet gain -< >-> destination + \-> multitap[0] -> ... -> multitap[n]-/ + + The multitap nodes simulate distinct echoes coming from the original sound. Multitap[0] is the original sound. + The predelay and convolver nodes simulate reverb. + */ + + this.#inputNode.connect(this.#gainNode); + this.#gainNode.connect(this.#wetGainNode); + this.#wetGainNode.connect(this.#preDelayNode); + this.#wetGainNode.connect(this.#multitapNodes[0]); + this.#multitapNodes.map((t, i) => { + if (this.#multitapNodes[i + 1]) { + t.connect(this.#multitapNodes[i + 1]); } }); + this.#multitapNodes[this.#multitapNodes.length - 1].connect(this.#multitapGainNode); + this.#multitapGainNode.connect(this.#destinationNode); + this.#preDelayNode.connect(this.#convolverNode); + this.#convolverNode.connect(this.#destinationNode); - this.#multitapGain = getApp().getAudioManager().getAudioContext().createGain(); - this.#multitap[this.#multitap.length - 1].connect(this.#multitapGain); - - this.#multitapGain.connect(this.#destinationNode); - this.#wet = getApp().getAudioManager().getAudioContext().createGain(); - - this.#gainNode.connect(this.#wet); - this.#wet.connect(this.#delay); - this.#wet.connect(this.#multitap[0]); - this.#delay.connect(this.#convolver); - - getApp().getAudioManager().getAudioContext().audioWorklet.addModule("audiodopplerprocessor.js").then(() => { - const randomNoiseNode = new AudioWorkletNode( - getApp().getAudioManager().getAudioContext(), - "audio-doppler-processor", - ); - this.#convolver.connect(randomNoiseNode); - randomNoiseNode.connect(this.#destinationNode); - }); - - - this.#renderTail(reverbTime); + /* Render the random noise needed for the convolver node to simulate reverb */ + this.#renderTail(0.1); //Arbitrary } #renderTail(reverbTime) { let attack = 0; let decay = 0.0; + /* Generate an offline audio context to render the reverb noise */ const tailContext = new OfflineAudioContext( 2, getApp().getAudioManager().getAudioContext().sampleRate * reverbTime, getApp().getAudioManager().getAudioContext().sampleRate ); + /* A noise oscillator and a two filters are added to smooth the reverb */ this.#tailOsc = new Noise(tailContext, 1); const tailLPFilter = new Filter(tailContext, "lowpass", 5000, 1); const tailHPFilter = new Filter(tailContext, "highpass", 500, 1); + /* Initialize and connect the oscillator with the filters */ this.#tailOsc.init(); this.#tailOsc.connect(tailHPFilter.input); tailHPFilter.connect(tailLPFilter.input); @@ -187,12 +196,13 @@ export class AudioUnitPipeline { this.#tailOsc.decay = decay; setTimeout(() => { + /* Set the buffer of the convolver node */ tailContext.startRendering().then((buffer) => { - this.#convolver.buffer = buffer; + this.#convolverNode.buffer = buffer; }); this.#tailOsc.on({ frequency: 500, velocity: 127 }); - //tailOsc.off(); + //tailOsc.off(); // TODO In the original example I copied, this was turned off. No idea why but it seems to work correctly if left on. To investigate. }, 20); } } diff --git a/frontend/react/src/audio/filesource.ts b/frontend/react/src/audio/filesource.ts index 247ac3c7..647c134a 100644 --- a/frontend/react/src/audio/filesource.ts +++ b/frontend/react/src/audio/filesource.ts @@ -1,9 +1,8 @@ import { AudioSource } from "./audiosource"; import { getApp } from "../olympusapp"; -import {WebAudioPeakMeter} from 'web-audio-peak-meter'; export class FileSource extends AudioSource { - #file: File | null = null; + #file: File; #source: AudioBufferSourceNode; #duration: number = 0; #currentPosition: number = 0; @@ -19,11 +18,8 @@ export class FileSource extends AudioSource { this.#file = file; this.setName(this.#file?.name ?? "N/A"); - - if (!this.#file) { - return; - } + /* Create the file reader and read the file from disk */ var reader = new FileReader(); reader.onload = (e) => { var contents = e.target?.result; @@ -31,6 +27,7 @@ export class FileSource extends AudioSource { getApp() .getAudioManager() .getAudioContext() + /* Decode the audio file. This method takes care of codecs */ .decodeAudioData(contents as ArrayBuffer, (audioBuffer) => { this.#audioBuffer = audioBuffer; this.#duration = audioBuffer.duration; @@ -41,11 +38,13 @@ export class FileSource extends AudioSource { } play() { + /* A new buffer source must be created every time the file is played */ this.#source = getApp().getAudioManager().getAudioContext().createBufferSource(); this.#source.buffer = this.#audioBuffer; this.#source.connect(this.getOutputNode()); this.#source.loop = this.#looping; + /* Start playing the file at the selected position */ this.#source.start(0, this.#currentPosition); this.#playing = true; const now = Date.now() / 1000; @@ -54,20 +53,22 @@ export class FileSource extends AudioSource { document.dispatchEvent(new CustomEvent("audioSourcesUpdated")); this.#updateInterval = setInterval(() => { + /* Update the current position value every second */ const now = Date.now() / 1000; this.#currentPosition += now - this.#lastUpdateTime; this.#lastUpdateTime = now; if (this.#currentPosition > this.#duration) { this.#currentPosition = 0; - if (!this.#looping) this.stop(); + if (!this.#looping) this.pause(); } document.dispatchEvent(new CustomEvent("audioSourcesUpdated")); }, 1000); } - stop() { + pause() { + /* Disconnect the source and update the position to the current time (precisely)*/ this.#source.stop(); this.#source.disconnect(); this.#playing = false; @@ -92,12 +93,17 @@ export class FileSource extends AudioSource { } setCurrentPosition(percentPosition) { + /* To change the current play position we must: + 1) pause the current playback; + 2) update the current position value; + 3) after some time, restart playing. The delay is needed to avoid immediately restarting many times if the user drags the position slider; + */ if (this.#playing) { clearTimeout(this.#restartTimeout); this.#restartTimeout = setTimeout(() => this.play(), 1000); } - this.stop(); + this.pause(); this.#currentPosition = (percentPosition / 100) * this.#duration; } diff --git a/frontend/react/src/audio/microphonesource.ts b/frontend/react/src/audio/microphonesource.ts index 048d6550..f744c804 100644 --- a/frontend/react/src/audio/microphonesource.ts +++ b/frontend/react/src/audio/microphonesource.ts @@ -2,7 +2,7 @@ import { getApp } from "../olympusapp"; import { AudioSource } from "./audiosource"; export class MicrophoneSource extends AudioSource { - #node: MediaStreamAudioSourceNode; + #sourceNode: MediaStreamAudioSourceNode; constructor() { super(); @@ -10,12 +10,12 @@ export class MicrophoneSource extends AudioSource { this.setName("Microphone"); } + /* Asynchronously initialize the microphone and connect it to the output node */ async initialize() { const microphone = await navigator.mediaDevices.getUserMedia({ audio: true }); if (getApp().getAudioManager().getAudioContext()) { - this.#node = getApp().getAudioManager().getAudioContext().createMediaStreamSource(microphone); - - this.#node.connect(this.getOutputNode()); + this.#sourceNode = getApp().getAudioManager().getAudioContext().createMediaStreamSource(microphone); + this.#sourceNode.connect(this.getOutputNode()); } } diff --git a/frontend/react/src/audio/playbackpipeline.ts b/frontend/react/src/audio/playbackpipeline.ts index 8f9a7c0f..2d0895ea 100644 --- a/frontend/react/src/audio/playbackpipeline.ts +++ b/frontend/react/src/audio/playbackpipeline.ts @@ -34,7 +34,7 @@ export class PlaybackPipeline { this.#gainNode.connect(getApp().getAudioManager().getAudioContext().destination); } - play(arrayBuffer) { + playBuffer(arrayBuffer) { const init = { type: "key", data: arrayBuffer, diff --git a/frontend/react/src/audio/radiosink.ts b/frontend/react/src/audio/radiosink.ts index 0baefbc8..f6767b4e 100644 --- a/frontend/react/src/audio/radiosink.ts +++ b/frontend/react/src/audio/radiosink.ts @@ -4,6 +4,7 @@ import { getApp } from "../olympusapp"; let packetID = 0; +/* Radio sink, basically implements a simple SRS Client in Olympus. Does not support encryption at this moment */ export class RadioSink extends AudioSink { #encoder: AudioEncoder; #desinationNode: MediaStreamAudioDestinationNode; diff --git a/frontend/react/src/audio/unitsink.ts b/frontend/react/src/audio/unitsink.ts index 76bad771..9d587bdf 100644 --- a/frontend/react/src/audio/unitsink.ts +++ b/frontend/react/src/audio/unitsink.ts @@ -3,27 +3,43 @@ import { getApp } from "../olympusapp"; import { Unit } from "../unit/unit"; import { AudioUnitPipeline } from "./audiounitpipeline"; +/* 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*/ export class UnitSink extends AudioSink { #unit: Unit; - #unitPipelines: {[key: string]: AudioUnitPipeline} = {}; + #unitPipelines: { [key: string]: AudioUnitPipeline } = {}; - constructor(sourceUnit: Unit) { + constructor(unit: Unit) { super(); - this.#unit = sourceUnit; - this.setName(`${sourceUnit.getUnitName()} - ${sourceUnit.getName()}`); + this.#unit = unit; + this.setName(`${unit.getUnitName()} - ${unit.getName()}`); - getApp() - .getAudioManager() - .getSRSClientsUnitIDs() - .forEach((unitID) => { - if (unitID !== 0) { - this.#unitPipelines[unitID] = new AudioUnitPipeline(sourceUnit, unitID, this.getInputNode()); - } - }); + document.addEventListener("SRSClientsUpdated", () => { + this.#updatePipelines(); + }); + + this.#updatePipelines(); } getUnit() { return this.#unit; } + + #updatePipelines() { + getApp() + .getAudioManager() + .getSRSClientsUnitIDs() + .forEach((unitID) => { + if (unitID !== 0 && !(unitID in this.#unitPipelines)) { + this.#unitPipelines[unitID] = new AudioUnitPipeline(this.#unit, unitID, this.getInputNode()); + } + }); + + Object.keys(this.#unitPipelines).forEach((unitID) => { + if (!(unitID in getApp().getAudioManager().getSRSClientsUnitIDs())) { + delete this.#unitPipelines[unitID]; + } + }); + } } diff --git a/frontend/react/src/constants/constants.ts b/frontend/react/src/constants/constants.ts index 9948ea2d..dd4506dc 100644 --- a/frontend/react/src/constants/constants.ts +++ b/frontend/react/src/constants/constants.ts @@ -237,6 +237,7 @@ export const defaultMapMirrors = {}; export const defaultMapLayers = {}; /* Map constants */ +export const NOT_INITIALIZED = "Not initialized"; export const IDLE = "Idle"; export const SPAWN_UNIT = "Spawn unit"; export const CONTEXT_ACTION = "Context action"; diff --git a/frontend/react/src/dom.d.ts b/frontend/react/src/dom.d.ts index 4367da9e..d918388a 100644 --- a/frontend/react/src/dom.d.ts +++ b/frontend/react/src/dom.d.ts @@ -28,6 +28,8 @@ interface CustomEventMap { hideUnitContextMenu: CustomEvent; audioSourcesUpdated: CustomEvent; audioSinksUpdated: CustomEvent; + audioManagerStateChanged: CustomEvent; + SRSClientsUpdated: CustomEvent; } declare global { diff --git a/frontend/react/src/map/map.ts b/frontend/react/src/map/map.ts index d5f33550..3b7f01ec 100644 --- a/frontend/react/src/map/map.ts +++ b/frontend/react/src/map/map.ts @@ -19,6 +19,7 @@ import { MAP_HIDDEN_TYPES_DEFAULTS, COALITIONAREA_EDIT, COALITIONAREA_DRAW_CIRCLE, + NOT_INITIALIZED, } from "../constants/constants"; import { CoalitionPolygon } from "./coalitionarea/coalitionpolygon"; import { MapHiddenTypes, MapOptions } from "../types/types"; @@ -46,7 +47,7 @@ export class Map extends L.Map { #hiddenTypes: MapHiddenTypes = MAP_HIDDEN_TYPES_DEFAULTS; /* State machine */ - #state: string; + #state: string = NOT_INITIALIZED; /* Map layers */ #theatre: string = ""; @@ -140,7 +141,7 @@ export class Map extends L.Map { this.#miniMapPolyline.addTo(this.#miniMapLayerGroup); /* Init the state machine */ - this.setState(IDLE); + setTimeout(() => this.setState(IDLE), 100); /* Register event handles */ this.on("zoomstart", (e: any) => this.#onZoomStart(e)); diff --git a/frontend/react/src/mission/airbase.ts b/frontend/react/src/mission/airbase.ts index 0c214126..1c4ec32c 100644 --- a/frontend/react/src/mission/airbase.ts +++ b/frontend/react/src/mission/airbase.ts @@ -14,11 +14,13 @@ export class Airbase extends CustomMarker { #coalition: string = ""; #properties: string[] = []; #parkings: string[] = []; + #img: HTMLImageElement; constructor(options: AirbaseOptions) { super(options.position, { riseOnHover: true }); this.#name = options.name; + this.#img = document.createElement("img"); } createIcon() { @@ -32,10 +34,10 @@ export class Airbase extends CustomMarker { var el = document.createElement("div"); el.classList.add("airbase-icon"); el.setAttribute("data-object", "airbase"); - var img = document.createElement("img"); - img.src = "/vite/images/markers/airbase.svg"; - img.onload = () => SVGInjector(img); - el.appendChild(img); + + this.#img.src = "/vite/images/markers/airbase.svg"; + this.#img.onload = () => SVGInjector(this.#img); + el.appendChild(this.#img); this.getElement()?.appendChild(el); el.addEventListener("mouseover", (ev) => { document.dispatchEvent(new CustomEvent("airbasemouseover", { detail: this })); @@ -86,4 +88,8 @@ export class Airbase extends CustomMarker { getParkings() { return this.#parkings; } + + getImg() { + return this.#img; + } } diff --git a/frontend/react/src/mission/carrier.ts b/frontend/react/src/mission/carrier.ts new file mode 100644 index 00000000..50c030ac --- /dev/null +++ b/frontend/react/src/mission/carrier.ts @@ -0,0 +1,58 @@ +import { DivIcon, LatLng, Map } from "leaflet"; +import { Airbase } from "./airbase"; + +export class Carrier extends Airbase { + #heading: number = 0; + + createIcon() { + var icon = new DivIcon({ + className: "leaflet-airbase-marker", + iconSize: [40, 40], + iconAnchor: [20, 20], + }); // Set the marker, className must be set to avoid white square + this.setIcon(icon); + + var el = document.createElement("div"); + el.classList.add("airbase-icon"); + el.setAttribute("data-object", "airbase"); + + this.getImg().src = "/vite/images/carriers/nimitz.png"; + this.getImg().style.width = `0px`; // Make the image immediately small to avoid giant carriers + el.appendChild(this.getImg()); + this.getElement()?.appendChild(el); + el.addEventListener("mouseover", (ev) => { + document.dispatchEvent(new CustomEvent("airbasemouseover", { detail: this })); + }); + el.addEventListener("mouseout", (ev) => { + document.dispatchEvent(new CustomEvent("airbasemouseout", { detail: this })); + }); + el.dataset.coalition = this.getCoalition(); + } + + onAdd(map: Map): this { + super.onAdd(map); + //this._map.on("zoomstart", (e: any) => this.updateSize()); + return this; + } + + onRemove(map: Map): this { + super.onRemove(map); + //this._map.off("zoomstart", (e: any) => this.updateSize()); + return this; + } + + setHeading(heading: number) { + this.#heading = heading; + this.getImg().style.transform = `rotate(${heading - 3.14 / 2}rad)`; + } + + updateSize() { + if (this._map) { + const y = this._map.getSize().y; + const x = this._map.getSize().x; + const maxMeters = this._map.containerPointToLatLng([0, y]).distanceTo(this._map.containerPointToLatLng([x, y])); + const meterPerPixel = maxMeters / x; + this.getImg().style.width = `${Math.round(333 / meterPerPixel)}px`; + } + } +} diff --git a/frontend/react/src/mission/missionmanager.ts b/frontend/react/src/mission/missionmanager.ts index f36b23d6..8215f9e0 100644 --- a/frontend/react/src/mission/missionmanager.ts +++ b/frontend/react/src/mission/missionmanager.ts @@ -12,11 +12,13 @@ import { navyUnitDatabase } from "../unit/databases/navyunitdatabase"; //import { Popup } from "../popups/popup"; import { AirbasesData, BullseyesData, CommandModeOptions, DateAndTime, MissionData } from "../interfaces"; import { Coalition } from "../types/types"; +import { Carrier } from "./carrier"; +import { NavyUnit } from "../unit/unit"; /** The MissionManager */ export class MissionManager { #bullseyes: { [name: string]: Bullseye } = {}; - #airbases: { [name: string]: Airbase } = {}; + #airbases: { [name: string]: Airbase | Carrier } = {}; #theatre: string = ""; #dateAndTime: DateAndTime = { date: { Year: 0, Month: 0, Day: 0 }, @@ -82,18 +84,21 @@ export class MissionManager { updateAirbases(data: AirbasesData) { for (let idx in data.airbases) { var airbase = data.airbases[idx]; - if (this.#airbases[airbase.callsign] === undefined && airbase.callsign != "") { - this.#airbases[airbase.callsign] = new Airbase({ - position: new LatLng(airbase.latitude, airbase.longitude), - name: airbase.callsign, - }).addTo(getApp().getMap()); - this.#airbases[airbase.callsign].on("click", (e) => this.#onAirbaseClick(e)); - this.#loadAirbaseChartData(airbase.callsign); + var airbaseCallsign = airbase.callsign !== ""? airbase.callsign: `carrier-${airbase.unitId}` + if (this.#airbases[airbaseCallsign] === undefined) { + if (airbase.callsign != "") { + this.#airbases[airbaseCallsign] = new Airbase({ + position: new LatLng(airbase.latitude, airbase.longitude), + name: airbaseCallsign, + }).addTo(getApp().getMap()); + this.#airbases[airbaseCallsign].on("click", (e) => this.#onAirbaseClick(e)); + this.#loadAirbaseChartData(airbaseCallsign); + } } - if (this.#airbases[airbase.callsign] != undefined && airbase.latitude && airbase.longitude && airbase.coalition) { - this.#airbases[airbase.callsign].setLatLng(new LatLng(airbase.latitude, airbase.longitude)); - this.#airbases[airbase.callsign].setCoalition(airbase.coalition); + if (this.#airbases[airbaseCallsign] != undefined && airbase.latitude && airbase.longitude && airbase.coalition) { + this.#airbases[airbaseCallsign].setLatLng(new LatLng(airbase.latitude, airbase.longitude)); + this.#airbases[airbaseCallsign].setCoalition(airbase.coalition); } } } diff --git a/frontend/react/src/olympusapp.ts b/frontend/react/src/olympusapp.ts index 43e3b7f9..7df58f4f 100644 --- a/frontend/react/src/olympusapp.ts +++ b/frontend/react/src/olympusapp.ts @@ -81,7 +81,7 @@ export class OlympusApp { getMissionManager() { return this.#missionManager as MissionManager; } - + getAudioManager() { return this.#audioManager as AudioManager; } diff --git a/frontend/react/src/shortcut/shortcutmanager.ts b/frontend/react/src/shortcut/shortcutmanager.ts index 38f59c83..6f790e03 100644 --- a/frontend/react/src/shortcut/shortcutmanager.ts +++ b/frontend/react/src/shortcut/shortcutmanager.ts @@ -1,3 +1,4 @@ +import { RadioSink } from "../audio/radiosink"; import { DEFAULT_CONTEXT } from "../constants/constants"; import { ShortcutKeyboardOptions, ShortcutMouseOptions } from "../interfaces"; import { getApp } from "../olympusapp"; @@ -119,7 +120,45 @@ export class ShortcutManager { shiftKey: false, }); - ["KeyW", "KeyA", "KeyS", "KeyD", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].forEach((code) => { + let PTTKeys = ["KeyZ", "KeyX", "KeyC", "KeyV", "KeyB", "KeyN", "KeyM", "KeyK", "KeyL"]; + PTTKeys.forEach((key, idx) => { + this.addKeyboardShortcut(`PTT${idx}Active`, { + altKey: false, + callback: () => { + getApp() + .getAudioManager() + .getSinks() + .filter((sink) => { + return sink instanceof RadioSink; + }) + [idx]?.setPtt(true); + }, + code: key, + context: DEFAULT_CONTEXT, + ctrlKey: false, + shiftKey: false, + event: "keydown", + }).addKeyboardShortcut(`PTT${idx}Active`, { + altKey: false, + callback: () => { + getApp() + .getAudioManager() + .getSinks() + .filter((sink) => { + return sink instanceof RadioSink; + }) + [idx]?.setPtt(false); + }, + code: key, + context: DEFAULT_CONTEXT, + ctrlKey: false, + shiftKey: false, + event: "keyup", + }); + }); + + let panKeys = ["KeyW", "KeyA", "KeyS", "KeyD", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]; + panKeys.forEach((code) => { this.addKeyboardShortcut(`pan${code}keydown`, { altKey: false, callback: (ev: KeyboardEvent) => { diff --git a/frontend/react/src/ui/panels/audiomenu.tsx b/frontend/react/src/ui/panels/audiomenu.tsx index 834de93c..e5a6ca23 100644 --- a/frontend/react/src/ui/panels/audiomenu.tsx +++ b/frontend/react/src/ui/panels/audiomenu.tsx @@ -4,9 +4,14 @@ import { getApp } from "../../olympusapp"; import { FaQuestionCircle } from "react-icons/fa"; import { AudioSourcePanel } from "./components/sourcepanel"; import { AudioSource } from "../../audio/audiosource"; +import { FaVolumeHigh, FaX } from "react-icons/fa6"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faClose } from "@fortawesome/free-solid-svg-icons"; export function AudioMenu(props: { open: boolean; onClose: () => void; children?: JSX.Element | JSX.Element[] }) { const [sources, setSources] = useState([] as AudioSource[]); + const [audioManagerEnabled, setAudioManagerEnabled] = useState(false); + const [showTip, setShowTip] = useState(true); useEffect(() => { /* Force a rerender */ @@ -18,21 +23,66 @@ export function AudioMenu(props: { open: boolean; onClose: () => void; children? .map((source) => source) ); }); - }, []); + document.addEventListener("audioManagerStateChanged", () => { + setAudioManagerEnabled(getApp().getAudioManager().isRunning()); + }); + }, []); return (
The audio source panel allows you to add and manage audio sources.
-
-
- -
-
-
Use the controls to apply effects and start/stop the playback of an audio source.
-
Sources can be connected to your radios, or attached to a unit to be played on loudspeakers.
-
-
+ <> + {showTip && ( +
+ {audioManagerEnabled ? ( + <> +
+ +
+
+
Use the controls to apply effects and start/stop the playback of an audio source.
+
Sources can be connected to your radios, or attached to a unit to be played on loudspeakers.
+
+
+ setShowTip(false)} + icon={faClose} + className={` + ml-2 flex cursor-pointer items-center justify-center + rounded-md p-2 text-lg + dark:text-gray-500 dark:hover:bg-gray-700 + dark:hover:text-white + hover:bg-gray-200 + `} + /> +
+ + ) : ( + <> +
+ +
+
+
+ To enable the audio menu, first start the audio backend with the{" "} + + + {" "} + button on the navigation header. +
+
+ + )} +
+ )} + +
void; children? `} > <> - {sources - .map((source) => { - return ; - })} + {sources.map((source, idx) => { + return ; + })} - + {audioManagerEnabled && ( + + )}
); diff --git a/frontend/react/src/ui/panels/components/menu.tsx b/frontend/react/src/ui/panels/components/menu.tsx index 6a57cdbc..34069b9c 100644 --- a/frontend/react/src/ui/panels/components/menu.tsx +++ b/frontend/react/src/ui/panels/components/menu.tsx @@ -30,9 +30,10 @@ export function Menu(props: {
) : ( )}
diff --git a/frontend/react/src/ui/panels/components/radiopanel.tsx b/frontend/react/src/ui/panels/components/radiopanel.tsx index 3209e686..f267c5af 100644 --- a/frontend/react/src/ui/panels/components/radiopanel.tsx +++ b/frontend/react/src/ui/panels/components/radiopanel.tsx @@ -7,7 +7,7 @@ import { faEarListen, faMicrophoneLines } from "@fortawesome/free-solid-svg-icon import { RadioSink } from "../../../audio/radiosink"; import { getApp } from "../../../olympusapp"; -export function RadioPanel(props: { radio: RadioSink }) { +export function RadioPanel(props: { radio: RadioSink; shortcutKey: string }) { return (
{props.radio.getName()} -
{getApp().getAudioManager().removeSink(props.radio);}}> +
{ + getApp().getAudioManager().removeSink(props.radio); + }} + >
{ - props.radio.setFrequency(value) + props.radio.setFrequency(value); }} />
@@ -37,8 +42,17 @@ export function RadioPanel(props: { radio: RadioSink }) { }} > + + {props.shortcutKey} + + { diff --git a/frontend/react/src/ui/panels/components/sourcepanel.tsx b/frontend/react/src/ui/panels/components/sourcepanel.tsx index de5e818e..d71d8a27 100644 --- a/frontend/react/src/ui/panels/components/sourcepanel.tsx +++ b/frontend/react/src/ui/panels/components/sourcepanel.tsx @@ -54,7 +54,7 @@ export function AudioSourcePanel(props: { source: AudioSource }) { checked={false} icon={props.source.getPlaying() ? faPause : faPlay} onClick={() => { - if (props.source instanceof FileSource) props.source.getPlaying() ? props.source.stop() : props.source.play(); + if (props.source instanceof FileSource) props.source.getPlaying() ? props.source.pause() : props.source.play(); }} tooltip="Play file" > @@ -106,9 +106,10 @@ export function AudioSourcePanel(props: { source: AudioSource }) { Connected to:
- {props.source.getConnectedTo().map((sink) => { + {props.source.getConnectedTo().map((sink, idx) => { return (
{availabileSinks.length > 0 && ( - {availabileSinks.map((sink) => { + {availabileSinks.map((sink, idx) => { return ( { props.source.connect(sink); }} diff --git a/frontend/react/src/ui/panels/controlspanel.tsx b/frontend/react/src/ui/panels/controlspanel.tsx index ec64ef1d..58989076 100644 --- a/frontend/react/src/ui/panels/controlspanel.tsx +++ b/frontend/react/src/ui/panels/controlspanel.tsx @@ -33,6 +33,7 @@ export function ControlsPanel(props: {}) { {controls.map((control) => { return (
{control.actions.map((action, idx) => { return ( - <> -
+
+
{typeof action === "string" || typeof action === "number" ? action : }
{idx < control.actions.length - 1 && typeof control.actions[idx + 1] === "string" &&
+
} {idx < control.actions.length - 1 && typeof control.actions[idx + 1] === "number" &&
x
} - +
); })}
diff --git a/frontend/react/src/ui/panels/drawingmenu.tsx b/frontend/react/src/ui/panels/drawingmenu.tsx index cb1271c1..d5989230 100644 --- a/frontend/react/src/ui/panels/drawingmenu.tsx +++ b/frontend/react/src/ui/panels/drawingmenu.tsx @@ -27,22 +27,27 @@ export function DrawingMenu(props: { open: boolean; onClose: () => void }) { const [erasSelection, setErasSelection] = useState({}); const [rangesSelection, setRangesSelection] = useState({}); + const [showPolygonTip, setShowPolygonTip] = useState(true); + const [showCircleTip, setShowCircleTip] = useState(true); + useEffect(() => { - /* If we are not in polygon drawing mode, force the draw polygon button off */ - if (drawingPolygon && getApp().getMap().getState() !== COALITIONAREA_DRAW_POLYGON) setDrawingPolygon(false); + if (getApp()) { + /* If we are not in polygon drawing mode, force the draw polygon button off */ + if (drawingPolygon && getApp().getMap().getState() !== COALITIONAREA_DRAW_POLYGON) setDrawingPolygon(false); - /* If we are not in circle drawing mode, force the draw circle button off */ - if (drawingCircle && getApp().getMap().getState() !== COALITIONAREA_DRAW_CIRCLE) setDrawingCircle(false); + /* If we are not in circle drawing mode, force the draw circle button off */ + if (drawingCircle && getApp().getMap().getState() !== COALITIONAREA_DRAW_CIRCLE) setDrawingCircle(false); - /* If we are not in any drawing mode, force the map in edit mode */ - if (props.open && !drawingPolygon && !drawingCircle) getApp().getMap().setState(COALITIONAREA_EDIT); + /* If we are not in any drawing mode, force the map in edit mode */ + if (props.open && !drawingPolygon && !drawingCircle) getApp().getMap().setState(COALITIONAREA_EDIT); - /* Align the state of the coalition toggle to the coalition of the area */ - if (activeCoalitionArea && activeCoalitionArea?.getCoalition() !== areaCoalition) setAreaCoalition(activeCoalitionArea?.getCoalition()); + /* Align the state of the coalition toggle to the coalition of the area */ + if (activeCoalitionArea && activeCoalitionArea?.getCoalition() !== areaCoalition) setAreaCoalition(activeCoalitionArea?.getCoalition()); - if (!props.open) { - if ([COALITIONAREA_EDIT, COALITIONAREA_DRAW_CIRCLE, COALITIONAREA_DRAW_POLYGON].includes(getApp()?.getMap()?.getState())) - getApp().getMap().setState(IDLE); + if (!props.open) { + if ([COALITIONAREA_EDIT, COALITIONAREA_DRAW_CIRCLE, COALITIONAREA_DRAW_POLYGON].includes(getApp().getMap().getState())) + getApp().getMap().setState(IDLE); + } } }); @@ -79,8 +84,8 @@ export function DrawingMenu(props: { open: boolean; onClose: () => void }) { The draw tool allows you to quickly draw areas on the map and use these areas to spawn units and activate triggers.
-
- +
+
Use the polygon or circle tool to draw areas on the map.
@@ -217,14 +222,14 @@ export function DrawingMenu(props: { open: boolean; onClose: () => void }) { {getApp() .getGroundUnitDatabase() .getTypes() - .map((type) => { + .map((type, idx) => { if (!(type in typesSelection)) { typesSelection[type] = true; setTypesSelection(JSON.parse(JSON.stringify(typesSelection))); } return ( - + { diff --git a/frontend/react/src/ui/panels/radiomenu.tsx b/frontend/react/src/ui/panels/radiomenu.tsx index 33a59534..9bd0d856 100644 --- a/frontend/react/src/ui/panels/radiomenu.tsx +++ b/frontend/react/src/ui/panels/radiomenu.tsx @@ -1,13 +1,19 @@ import React, { useEffect, useState } from "react"; import { Menu } from "./components/menu"; import { getApp } from "../../olympusapp"; -import { OlToggle } from "../components/oltoggle"; import { RadioPanel } from "./components/radiopanel"; import { FaQuestionCircle } from "react-icons/fa"; import { RadioSink } from "../../audio/radiosink"; +import { FaVolumeHigh } from "react-icons/fa6"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faClose } from "@fortawesome/free-solid-svg-icons"; + +let shortcutKeys = ["Z", "X", "C", "V", "B", "N", "M", "K", "L"]; export function RadioMenu(props: { open: boolean; onClose: () => void; children?: JSX.Element | JSX.Element[] }) { const [radios, setRadios] = useState([] as RadioSink[]); + const [audioManagerEnabled, setAudioManagerEnabled] = useState(false); + const [showTip, setShowTip] = useState(true); useEffect(() => { /* Force a rerender */ @@ -20,30 +26,76 @@ export function RadioMenu(props: { open: boolean; onClose: () => void; children? .map((radio) => radio) ); }); + + document.addEventListener("audioManagerStateChanged", () => { + setAudioManagerEnabled(getApp().getAudioManager().isRunning()); + }); }, []); return (
The radio menu allows you to talk on radio to the players online using SRS.
-
-
- -
-
-
Use the radio controls to tune to a frequency, then click on the PTT button to talk.
-
You can add up to 10 radios. Use the audio effects menu to play audio tracks or to add background noises.
-
-
+ <> + {showTip && ( +
+ {audioManagerEnabled ? ( + <> +
+ +
+
+
Use the radio controls to tune to a frequency, then click on the PTT button to talk.
+
You can add up to 10 radios. Use the audio effects menu to play audio tracks or to add background noises.
+
+
+ setShowTip(false)} + icon={faClose} + className={` + ml-2 flex cursor-pointer items-center justify-center + rounded-md p-2 text-lg + dark:text-gray-500 dark:hover:bg-gray-700 + dark:hover:text-white + hover:bg-gray-200 + `} + /> +
+ + ) : ( + <> +
+ +
+
+
+ To enable the radio menu, first start the audio backend with the{" "} + + + {" "} + button on the navigation header. +
+
+ + )} +
+ )} + +
- {radios.map((radio) => { - return ; + {radios.map((radio, idx) => { + return ; })} - {radios.length < 10 && ( + {audioManagerEnabled && radios.length < 10 && (