mirror of
https://github.com/Pax1601/DCSOlympus.git
synced 2025-10-29 16:56:34 +00:00
491 lines
22 KiB
TypeScript
491 lines
22 KiB
TypeScript
import React, { useEffect, useRef, useState } from "react";
|
|
import { Menu } from "./components/menu";
|
|
import { getApp } from "../../olympusapp";
|
|
import { FaExclamation, FaExclamationCircle, FaPlus, FaQuestionCircle } from "react-icons/fa";
|
|
import { AudioSourcePanel } from "./components/sourcepanel";
|
|
import { AudioSource } from "../../audio/audiosource";
|
|
import { RadioSinkPanel } from "./components/radiosinkpanel";
|
|
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 {
|
|
AudioManagerCoalitionChangedEvent,
|
|
AudioManagerDevicesChangedEvent,
|
|
AudioManagerInputChangedEvent,
|
|
AudioManagerOutputChangedEvent,
|
|
AudioManagerStateChangedEvent,
|
|
AudioSinksChangedEvent,
|
|
AudioSourcesChangedEvent,
|
|
CommandModeOptionsChangedEvent,
|
|
ShortcutsChangedEvent,
|
|
} 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";
|
|
|
|
export function AudioMenu(props: { open: boolean; onClose: () => void; children?: JSX.Element | JSX.Element[] }) {
|
|
const [devices, setDevices] = useState([] as MediaDeviceInfo[]);
|
|
const [sinks, setSinks] = useState([] as AudioSink[]);
|
|
const [sources, setSources] = useState([] as AudioSource[]);
|
|
const [audioManagerEnabled, setAudioManagerEnabled] = useState(false);
|
|
const [activeSource, setActiveSource] = useState(null as AudioSource | null);
|
|
const [count, setCount] = useState(0);
|
|
const [shortcuts, setShortcuts] = useState({});
|
|
const [input, setInput] = useState(undefined as undefined | MediaDeviceInfo);
|
|
const [output, setOutput] = useState(undefined as undefined | MediaDeviceInfo);
|
|
const [coalition, setCoalition] = useState("blue" as Coalition);
|
|
const [commandMode, setCommandMode] = useState(NONE as string);
|
|
|
|
/* Preallocate 128 references for the source and sink panels. If the number of references changes, React will give an error */
|
|
const sourceRefs = Array(128)
|
|
.fill(null)
|
|
.map(() => {
|
|
return useRef(null);
|
|
});
|
|
|
|
const sinkRefs = Array(128)
|
|
.fill(null)
|
|
.map(() => {
|
|
return useRef(null);
|
|
});
|
|
|
|
useEffect(() => {
|
|
/* Force a rerender */
|
|
AudioSinksChangedEvent.on(() => {
|
|
setSinks(
|
|
getApp()
|
|
?.getAudioManager()
|
|
.getSinks()
|
|
.filter((sink) => sink instanceof AudioSink)
|
|
.map((radio) => radio)
|
|
);
|
|
});
|
|
|
|
/* Force a rerender */
|
|
AudioSourcesChangedEvent.on(() => {
|
|
setSources(
|
|
getApp()
|
|
?.getAudioManager()
|
|
.getSources()
|
|
.map((source) => source)
|
|
);
|
|
});
|
|
|
|
AudioManagerStateChangedEvent.on(() => {
|
|
setAudioManagerEnabled(getApp().getAudioManager().isRunning());
|
|
});
|
|
|
|
ShortcutsChangedEvent.on((shortcuts) => setShortcuts(shortcuts));
|
|
|
|
AudioManagerDevicesChangedEvent.on((devices) => setDevices([...devices]));
|
|
AudioManagerInputChangedEvent.on((input) => setInput(input));
|
|
AudioManagerOutputChangedEvent.on((output) => setOutput(output));
|
|
AudioManagerCoalitionChangedEvent.on((coalition) => setCoalition(coalition));
|
|
CommandModeOptionsChangedEvent.on((options) => setCommandMode(options.commandMode));
|
|
}, []);
|
|
|
|
/* When the sinks or sources change, use the count state to force a rerender to update the connection lines */
|
|
useEffect(() => {
|
|
setCount(count + 1);
|
|
}, [sinks, sources]);
|
|
|
|
/* List all the connections between the sinks and the sources */
|
|
const connections = [] as any[];
|
|
const lineCounters = [] as number[];
|
|
const lineColors = [] as string[];
|
|
let counter = 0;
|
|
sources.forEach((source, idx) => {
|
|
counter++;
|
|
const color = getRandomColor(counter);
|
|
source.getConnectedTo().forEach((sink) => {
|
|
if (sinks.indexOf(sink as AudioSink) !== undefined) {
|
|
connections.push([sourceRefs[idx], sinkRefs[sinks.indexOf(sink as AudioSink)]]);
|
|
lineCounters.push(counter);
|
|
lineColors.push(color);
|
|
}
|
|
});
|
|
});
|
|
|
|
/* Compute the line distance to fit in the available space */
|
|
const defaultLineDistance = 8;
|
|
const paddingRight = Math.min(lineCounters[lineCounters.length - 1] * defaultLineDistance + 40, 96);
|
|
const lineDistance = (paddingRight - 40) / lineCounters[lineCounters.length - 1];
|
|
|
|
return (
|
|
<Menu
|
|
title="Audio menu"
|
|
open={props.open}
|
|
showBackButton={false}
|
|
onClose={props.onClose}
|
|
wiki={() => {
|
|
return (
|
|
<div
|
|
className={`
|
|
h-full flex-col overflow-auto p-4 text-gray-400 no-scrollbar flex
|
|
gap-2
|
|
`}
|
|
>
|
|
<h2 className="mb-4 font-bold">Audio menu</h2>
|
|
<div>
|
|
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.
|
|
</div>
|
|
<div>
|
|
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.
|
|
</div>
|
|
<div className="text-red-500">
|
|
For security reasons, the audio backend will only work if the page is served over HTTPS.
|
|
</div>
|
|
<h2 className="my-4 font-bold">Managing the audio backend</h2>
|
|
<div>
|
|
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.
|
|
</div>
|
|
<div>
|
|
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.
|
|
</div>
|
|
<h2 className="my-4 font-bold">Creating audio sources</h2>
|
|
<div>
|
|
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.
|
|
</div>
|
|
<div>
|
|
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 */}
|
|
</div>
|
|
<div>
|
|
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.
|
|
</div>
|
|
<h2 className="my-4 font-bold">Creating radios and loudspeakers</h2>
|
|
<div>
|
|
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.
|
|
</div>
|
|
<div>
|
|
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.
|
|
</div>
|
|
<div>
|
|
You have three options to transmit on the radio:
|
|
<div>
|
|
<li>By clicking on the "Talk on frequency" button on the radio panel. This will enable continuous transmission and will remain "on" until clicked again.</li>
|
|
<li>By clicking on the "Push to talk" button located over the mouse coordinates panel, on the bottom right corner of the map.</li>
|
|
<li>By using the "Push to talk" keyboard shortcuts, which can be edited in the options menu.</li>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
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.
|
|
</div>
|
|
<div className="text-red-500">
|
|
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).
|
|
</div>
|
|
<h2 className="my-4 font-bold">Connecting sources and radios/loudspeakers</h2>
|
|
<div>
|
|
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.
|
|
</div>
|
|
<div>
|
|
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.
|
|
</div>
|
|
<div>
|
|
By connecting multiple sources to the same radio/loudspeaker, you can create complex audio setups, like playing background music while transmitting on the radio.
|
|
</div>
|
|
</div>
|
|
);
|
|
}}
|
|
>
|
|
<div className="flex content-center gap-4 p-4">
|
|
<div className="my-auto text-gray-400">
|
|
<FaQuestionCircle />
|
|
</div>
|
|
<div className="text-sm text-gray-400">
|
|
The audio menu allows you to add and manage audio sources, connect them to unit loudspeakers and radios, and to tune radio frequencies.
|
|
</div>
|
|
</div>
|
|
|
|
<>
|
|
{!audioManagerEnabled && (
|
|
<div className="mx-4 flex gap-4 rounded-lg bg-olympus-400 p-4 text-sm">
|
|
<div className="my-auto animate-bounce text-xl">
|
|
<FaExclamationCircle className="text-gray-400" />
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<div className="text-gray-100">
|
|
To enable the audio menu, first start the audio backend with the{" "}
|
|
<span
|
|
className={`
|
|
mx-1 mt-[-7px] inline-block translate-y-2 rounded-full
|
|
border-[1px] border-white p-1
|
|
`}
|
|
>
|
|
<FaVolumeHigh />
|
|
</span>{" "}
|
|
button on the navigation header.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
|
|
<div className="flex flex-col gap-3">
|
|
<div
|
|
className={`
|
|
flex flex-col gap-2 px-5 font-normal text-gray-800
|
|
dark:text-white
|
|
`}
|
|
style={{ paddingRight: `${paddingRight}px` }}
|
|
>
|
|
{audioManagerEnabled && (
|
|
<>
|
|
{commandMode === GAME_MASTER && (
|
|
<div className="flex justify-between">
|
|
<div>Radio coalition </div>
|
|
<OlCoalitionToggle
|
|
coalition={coalition}
|
|
onClick={() => {
|
|
let newCoalition = "";
|
|
if (coalition === "blue") newCoalition = "neutral";
|
|
else if (coalition === "neutral") newCoalition = "red";
|
|
else if (coalition === "red") newCoalition = "blue";
|
|
getApp()
|
|
.getAudioManager()
|
|
.setCoalition(newCoalition as Coalition);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<span>Input</span>
|
|
|
|
<OlDropdown label={input ? input.label : "Default"}>
|
|
{devices
|
|
.filter((device) => device.kind === "audioinput")
|
|
.map((device, idx) => {
|
|
return (
|
|
<OlDropdownItem onClick={() => getApp().getAudioManager().setInput(device)}>
|
|
<div className="w-full truncate">{device.label}</div>
|
|
</OlDropdownItem>
|
|
);
|
|
})}
|
|
</OlDropdown>
|
|
</>
|
|
)}
|
|
{audioManagerEnabled && (
|
|
<>
|
|
{" "}
|
|
<span>Output</span>
|
|
<OlDropdown label={output ? output.label : "Default"}>
|
|
{devices
|
|
.filter((device) => device.kind === "audiooutput")
|
|
.map((device, idx) => {
|
|
return (
|
|
<OlDropdownItem onClick={() => getApp().getAudioManager().setOutput(device)}>
|
|
<div className="w-full truncate">{device.label}</div>
|
|
</OlDropdownItem>
|
|
);
|
|
})}
|
|
</OlDropdown>
|
|
</>
|
|
)}
|
|
{audioManagerEnabled && <span>Audio sources</span>}
|
|
<>
|
|
{sources.map((source, idx) => {
|
|
return (
|
|
<AudioSourcePanel
|
|
key={idx}
|
|
source={source}
|
|
ref={sourceRefs[idx]}
|
|
onExpanded={() => {
|
|
setCount(count + 1);
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
</>
|
|
{audioManagerEnabled && (
|
|
<button
|
|
type="button"
|
|
className={`
|
|
mb-2 rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-medium
|
|
text-white
|
|
dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800
|
|
focus:outline-none focus:ring-4 focus:ring-blue-300
|
|
hover:bg-blue-800
|
|
`}
|
|
onClick={() => getApp().getAudioManager().addFileSource()}
|
|
>
|
|
Add audio source
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div
|
|
className={`
|
|
flex flex-col gap-2 px-5 font-normal text-gray-800
|
|
dark:text-white
|
|
`}
|
|
style={{ paddingRight: `${paddingRight}px` }}
|
|
>
|
|
{audioManagerEnabled && <span>Radios</span>}
|
|
{sinks.map((sink, idx) => {
|
|
if (sink instanceof RadioSink)
|
|
return (
|
|
<RadioSinkPanel
|
|
shortcutKeys={shortcuts[`PTT${idx}Active`].toActions()}
|
|
key={sink.getName()}
|
|
radio={sink}
|
|
onExpanded={() => {
|
|
setCount(count + 1);
|
|
}}
|
|
ref={sinkRefs[idx]}
|
|
></RadioSinkPanel>
|
|
);
|
|
})}
|
|
{audioManagerEnabled && sinks.filter((sink) => sink instanceof RadioSink).length < 10 && (
|
|
<button
|
|
type="button"
|
|
className={`
|
|
mb-2 rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-medium
|
|
text-white
|
|
dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800
|
|
focus:outline-none focus:ring-4 focus:ring-blue-300
|
|
hover:bg-blue-800
|
|
`}
|
|
onClick={() => getApp().getAudioManager().addRadio()}
|
|
>
|
|
Add radio
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div
|
|
className={`
|
|
flex flex-col gap-2 px-5 font-normal text-gray-800
|
|
dark:text-white
|
|
`}
|
|
style={{ paddingRight: `${paddingRight}px` }}
|
|
>
|
|
{audioManagerEnabled && sinks.find((sink) => sink instanceof UnitSink) && <span>Unit loudspeakers</span>}
|
|
{sinks.map((sink, idx) => {
|
|
if (sink instanceof UnitSink)
|
|
return (
|
|
<UnitSinkPanel
|
|
shortcutKeys={shortcuts[`PTT${idx}Active`].toActions()}
|
|
key={sink.getName()}
|
|
sink={sink}
|
|
ref={sinkRefs[idx]}
|
|
onExpanded={() => {
|
|
setCount(count + 1);
|
|
}}
|
|
></UnitSinkPanel>
|
|
);
|
|
})}
|
|
</div>
|
|
<div>
|
|
{connections
|
|
.filter((connection) => connection && connection[0].current && connection[1].current)
|
|
.map((connection, idx) => {
|
|
const start = connection[0].current;
|
|
const end = connection[1].current;
|
|
if (start && end) {
|
|
const startRect = {
|
|
top: (start as HTMLDivElement).offsetTop,
|
|
bottom: (start as HTMLDivElement).offsetTop + (start as HTMLDivElement).clientHeight,
|
|
height: (start as HTMLDivElement).clientHeight,
|
|
right: (start as HTMLDivElement).offsetLeft + (start as HTMLDivElement).clientWidth,
|
|
};
|
|
|
|
const endRect = {
|
|
top: (end as HTMLDivElement).offsetTop,
|
|
bottom: (end as HTMLDivElement).offsetTop + (end as HTMLDivElement).clientHeight,
|
|
height: (end as HTMLDivElement).clientHeight,
|
|
right: (end as HTMLDivElement).offsetLeft + (end as HTMLDivElement).clientWidth,
|
|
};
|
|
return (
|
|
<div
|
|
key={idx}
|
|
className={`
|
|
absolute rounded-br-md rounded-tr-md border-2 border-l-0
|
|
`}
|
|
style={{
|
|
top: `${(startRect.bottom + startRect.top) / 2}px`,
|
|
left: `${startRect.right}px`,
|
|
height: `${endRect.top - startRect.top + (endRect.height - startRect.height) / 2}px`,
|
|
width: `${(lineCounters[idx] - 1) * lineDistance + 30}px`,
|
|
borderColor: lineColors[idx],
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
})
|
|
.reverse()}
|
|
</div>
|
|
<div>
|
|
{sourceRefs.map((sourceRef, idx) => {
|
|
const div = sourceRef.current;
|
|
if (div) {
|
|
const divRect = {
|
|
top: (div as HTMLDivElement).offsetTop,
|
|
bottom: (div as HTMLDivElement).offsetTop + (div as HTMLDivElement).clientHeight,
|
|
height: (div as HTMLDivElement).clientHeight,
|
|
right: (div as HTMLDivElement).offsetLeft + (div as HTMLDivElement).clientWidth,
|
|
};
|
|
return (
|
|
<div
|
|
key={idx}
|
|
data-active={activeSource === sources[idx]}
|
|
className={`
|
|
absolute translate-y-[-50%] cursor-pointer rounded-full
|
|
bg-blue-600 p-1 text-xs text-white
|
|
data-[active='true']:bg-white
|
|
data-[active='true']:text-blue-600
|
|
hover:bg-blue-800
|
|
`}
|
|
style={{
|
|
top: `${(divRect.bottom + divRect.top) / 2}px`,
|
|
left: `${divRect.right - 10}px`,
|
|
}}
|
|
onClick={() => {
|
|
activeSource !== sources[idx] ? setActiveSource(sources[idx]) : setActiveSource(null);
|
|
}}
|
|
>
|
|
<FaPlus></FaPlus>
|
|
</div>
|
|
);
|
|
}
|
|
})}
|
|
{activeSource &&
|
|
sinkRefs.map((sinkRef, idx) => {
|
|
const div = sinkRef.current;
|
|
if (div) {
|
|
const divRect = {
|
|
top: (div as HTMLDivElement).offsetTop,
|
|
bottom: (div as HTMLDivElement).offsetTop + (div as HTMLDivElement).clientHeight,
|
|
height: (div as HTMLDivElement).clientHeight,
|
|
right: (div as HTMLDivElement).offsetLeft + (div as HTMLDivElement).clientWidth,
|
|
};
|
|
return (
|
|
<div
|
|
key={idx}
|
|
className={`
|
|
absolute translate-y-[-50%] cursor-pointer rounded-full
|
|
bg-blue-600 p-1 text-xs text-white
|
|
hover:bg-blue-800
|
|
`}
|
|
style={{
|
|
top: `${(divRect.bottom + divRect.top) / 2}px`,
|
|
left: `${divRect.right - 10}px`,
|
|
}}
|
|
onClick={() => {
|
|
if (activeSource.getConnectedTo().includes(sinks[idx])) activeSource.disconnect(sinks[idx]);
|
|
else activeSource.connect(sinks[idx]);
|
|
}}
|
|
>
|
|
{" "}
|
|
{activeSource.getConnectedTo().includes(sinks[idx]) ? <FaMinus></FaMinus> : <FaPlus></FaPlus>}
|
|
</div>
|
|
);
|
|
}
|
|
})}
|
|
</div>
|
|
</div>
|
|
</Menu>
|
|
);
|
|
}
|