More work on audio

This commit is contained in:
Pax1601 2024-09-04 19:41:05 +02:00
parent a64ccab15f
commit 9bbcdac704
22 changed files with 555 additions and 291 deletions

View File

@ -1,12 +1,15 @@
import { AudioMessageType } from "../constants/constants";
import { MicrophoneSource } from "./microphonesource";
import { SRSRadio } from "./srsradio";
import { RadioSink } from "./radiosink";
import { getApp } from "../olympusapp";
import { fromBytes, makeID } from "../other/utils";
import { AudioFileSource } from "./audiofilesource";
import { FileSource } from "./filesource";
import { AudioSource } from "./audiosource";
import { Buffer } from "buffer";
import { PlaybackPipeline } from "./playbackpipeline";
import { AudioSink } from "./audiosink";
import { Unit } from "../unit/unit";
import { UnitSink } from "./unitsink";
export class AudioManager {
#audioContext: AudioContext;
@ -14,8 +17,8 @@ export class AudioManager {
/* The playback pipeline enables audio playback on the speakers/headphones */
#playbackPipeline: PlaybackPipeline;
/* The SRS radio audio sinks used to transmit the audio stream to the SRS backend */
#radios: SRSRadio[] = [];
/* The audio sinks used to transmit the audio stream to the SRS backend */
#sinks: AudioSink[] = [];
/* List of all possible audio sources (microphone, file stream etc...) */
#sources: AudioSource[] = [];
@ -59,44 +62,49 @@ export class AudioManager {
/* Handle the reception of a new message */
this.#socket.addEventListener("message", (event) => {
this.#radios.forEach(async (radio) => {
/* Extract the audio data as array */
let packetUint8Array = new Uint8Array(await event.data.arrayBuffer());
this.#sinks.forEach(async (sink) => {
if (sink instanceof RadioSink) {
/* Extract the audio data as array */
let packetUint8Array = new Uint8Array(await event.data.arrayBuffer());
/* Extract the encoded audio data */
let audioLength = fromBytes(packetUint8Array.slice(2, 4));
let audioUint8Array = packetUint8Array.slice(6, 6 + audioLength);
/* Extract the encoded audio data */
let audioLength = fromBytes(packetUint8Array.slice(2, 4));
let audioUint8Array = packetUint8Array.slice(6, 6 + audioLength);
/* Extract the frequency value and play it on the speakers if we are listening to it*/
let frequency = new DataView(packetUint8Array.slice(6 + audioLength, 6 + audioLength + 8).reverse().buffer).getFloat64(0);
if (radio.getSetting().frequency === frequency) {
this.#playbackPipeline.play(audioUint8Array.buffer);
/* Extract the frequency value and play it on the speakers if we are listening to it*/
let frequency = new DataView(packetUint8Array.slice(6 + audioLength, 6 + audioLength + 8).reverse().buffer).getFloat64(0);
if (sink.getFrequency() === frequency) {
this.#playbackPipeline.play(audioUint8Array.buffer);
}
}
});
});
/* Add two default radios */
this.#radios = [new SRSRadio(), new SRSRadio()];
document.dispatchEvent(new CustomEvent("radiosUpdated"));
/* Add the microphone source and connect it directly to the radio */
const microphoneSource = new MicrophoneSource();
microphoneSource.initialize().then(() => {
this.#radios.forEach((radio) => {
microphoneSource.getNode().connect(radio.getNode());
this.#sinks.forEach((sink) => {
if (sink instanceof RadioSink)
microphoneSource.connect(sink);
});
this.#sources.push(microphoneSource);
document.dispatchEvent(new CustomEvent("audioSourcesUpdated"));
/* Add two default radios */
this.addRadio();
this.addRadio();
});
}
stop() {
this.#sources.forEach((source) => {
source.getNode().disconnect();
source.disconnect();
});
this.#sources = [];
this.#sinks = [];
this.#radios = [];
document.dispatchEvent(new CustomEvent("audioSourcesUpdated"));
document.dispatchEvent(new CustomEvent("audioSinksUpdated"));
}
setAddress(address) {
@ -108,27 +116,38 @@ export class AudioManager {
}
addFileSource(file) {
const newSource = new AudioFileSource(file);
const newSource = new FileSource(file);
this.#sources.push(newSource);
newSource.getNode().connect(this.#radios[0].getNode());
newSource.connect(this.#sinks[0]);
document.dispatchEvent(new CustomEvent("audioSourcesUpdated"));
}
getRadios() {
return this.#radios;
addUnitSink(unit: Unit) {
this.#sinks.push(new UnitSink(unit));
document.dispatchEvent(new CustomEvent("audioSinksUpdated"));
}
getSinks() {
return this.#sinks;
}
addRadio() {
const newRadio = new SRSRadio();
this.#sources[0].getNode().connect(newRadio.getNode());
this.#radios.push(newRadio);
document.dispatchEvent(new CustomEvent("radiosUpdated"));
const newRadio = new RadioSink();
this.#sinks.push(newRadio);
newRadio.setName(`Radio ${this.#sinks.length}`);
this.#sources[0].connect(newRadio);
document.dispatchEvent(new CustomEvent("audioSinksUpdated"));
}
removeRadio(idx) {
this.#radios[idx].getNode().disconnect();
this.#radios.splice(idx, 1);
document.dispatchEvent(new CustomEvent("radiosUpdated"));
removeSink(sink) {
sink.disconnect();
this.#sinks = this.#sinks.filter((v) => v != sink);
let idx = 1;
this.#sinks.forEach((sink) => {
if (sink instanceof RadioSink)
sink.setName(`Radio ${idx++}`);
});
document.dispatchEvent(new CustomEvent("audioSinksUpdated"));
}
getSources() {
@ -152,8 +171,12 @@ export class AudioManager {
type: "Settings update",
guid: this.#guid,
coalition: 2,
settings: this.#radios.map((radio) => {
return radio.getSetting();
settings: this.#sinks.filter((sink) => sink instanceof RadioSink).map((radio) => {
return {
frequency: radio.getFrequency(),
modulation: radio.getModulation(),
ptt: radio.getPtt(),
};
}),
};

View File

@ -30,7 +30,7 @@ export class AudioPacket {
let encModulation: number[] = [settings.modulation];
let encEncryption: number[] = [0];
let encUnitID: number[] = getBytes(100000001, 4);
let encUnitID: number[] = getBytes(0, 4);
let encPacketID: number[] = getBytes(packetID, 8);
packetID++;
let encHops: number[] = [0];

View File

@ -1,3 +1,70 @@
import { getApp } from "../olympusapp";
export abstract class AudioSink {
abstract getNode(): AudioNode;
}
#encoder: AudioEncoder;
#name: string;
#node: MediaStreamAudioDestinationNode;
#audioTrackProcessor: any; // TODO can we have typings?
#gainNode: GainNode;
constructor() {
/* A gain node is used because it allows to connect multiple inputs */
this.#gainNode = getApp().getAudioManager().getAudioContext().createGain();
this.#node = getApp().getAudioManager().getAudioContext().createMediaStreamDestination();
this.#node.channelCount = 1;
this.#encoder = new AudioEncoder({
output: (data) => this.handleEncodedData(data),
error: (e) => {
console.log(e);
},
});
this.#encoder.configure({
codec: "opus",
numberOfChannels: 1,
sampleRate: 16000,
//@ts-ignore // TODO why is it giving error?
opus: {
frameDuration: 40000,
},
bitrateMode: "constant",
});
//@ts-ignore
this.#audioTrackProcessor = new MediaStreamTrackProcessor({
track: this.#node.stream.getAudioTracks()[0],
});
this.#audioTrackProcessor.readable.pipeTo(
new WritableStream({
write: (arrayBuffer) => this.#handleRawData(arrayBuffer),
})
);
this.#gainNode.connect(this.#node);
}
setName(name) {
this.#name = name;
}
getName() {
return this.#name;
}
disconnect() {
this.getNode().disconnect();
document.dispatchEvent(new CustomEvent("audioSinksUpdated"));
}
getNode() {
return this.#gainNode;
}
#handleRawData(audioData) {
this.#encoder.encode(audioData);
audioData.close();
}
abstract handleEncodedData(encodedAudioChunk: EncodedAudioChunk): void;
}

View File

@ -1,22 +1,33 @@
import { AudioSourceSetting } from "../interfaces";
import { AudioSink } from "./audiosink";
export abstract class AudioSource {
#setting: AudioSourceSetting = {
connectedTo: "",
filename: "",
playing: true,
};
#connectedTo: AudioSink[] = [];
#name = "";
#playing = false;
getSetting() {
return this.#setting;
connect(sink: AudioSink) {
this.getNode().connect(sink.getNode());
this.#connectedTo.push(sink);
document.dispatchEvent(new CustomEvent("audioSourcesUpdated"));
}
setSetting(setting: AudioSourceSetting) {
this.#setting = setting;
disconnect() {
this.getNode().disconnect();
document.dispatchEvent(new CustomEvent("audioSourcesUpdated"));
}
setName(name) {
this.#name = name;
}
getName() {
return this.#name;
}
getConnectedTo() {
return this.#connectedTo;
}
abstract play(): void;
abstract getNode(): AudioNode;
abstract getName(): string;
}

View File

@ -2,7 +2,7 @@ import { AudioSource } from "./audiosource";
import { bufferToF32Planar } from "../other/utils";
import { getApp } from "../olympusapp";
export class AudioFileSource extends AudioSource {
export class FileSource extends AudioSource {
#gainNode: GainNode;
#file: File | null = null;
#source: AudioBufferSourceNode;
@ -11,6 +11,8 @@ export class AudioFileSource extends AudioSource {
super();
this.#file = file;
this.setName(this.#file?.name ?? "N/A");
this.#gainNode = getApp().getAudioManager().getAudioContext().createGain();
}
@ -30,6 +32,7 @@ export class AudioFileSource extends AudioSource {
this.#source = getApp().getAudioManager().getAudioContext().createBufferSource();
this.#source.buffer = arrayBuffer;
this.#source.connect(this.#gainNode);
this.#source.loop = true;
this.#source.start();
});
}
@ -44,8 +47,4 @@ export class AudioFileSource extends AudioSource {
setGain(gain) {
this.#gainNode.gain.setValueAtTime(gain, getApp().getAudioManager().getAudioContext().currentTime);
}
getName() {
return this.#file?.name ?? "N/A";
}
}

View File

@ -6,6 +6,8 @@ export class MicrophoneSource extends AudioSource {
constructor() {
super();
this.setName("Microphone");
}
async initialize() {
@ -22,8 +24,4 @@ export class MicrophoneSource extends AudioSource {
play() {
// TODO, now is always on
}
getName() {
return "Microphone"
}
}

View File

@ -0,0 +1,77 @@
import { AudioSink } from "./audiosink";
import { AudioPacket } from "./audiopacket";
import { getApp } from "../olympusapp";
export class RadioSink extends AudioSink {
#frequency = 251000000;
#modulation = 0;
#ptt = false;
#tuned = false;
#volume = 0.5;
constructor() {
super();
}
setFrequency(frequency) {
this.#frequency = frequency;
document.dispatchEvent(new CustomEvent("audioSinksUpdated"));
}
getFrequency() {
return this.#frequency;
}
setModulation(modulation) {
this.#modulation = modulation;
document.dispatchEvent(new CustomEvent("audioSinksUpdated"));
}
getModulation() {
return this.#modulation;
}
setPtt(ptt) {
this.#ptt = ptt;
document.dispatchEvent(new CustomEvent("audioSinksUpdated"));
}
getPtt() {
return this.#ptt;
}
setTuned(tuned) {
this.#tuned = tuned;
document.dispatchEvent(new CustomEvent("audioSinksUpdated"));
}
getTuned() {
return this.#tuned;
}
setVolume(volume) {
this.#volume = volume;
document.dispatchEvent(new CustomEvent("audioSinksUpdated"));
}
getVolume() {
return this.#volume;
}
handleEncodedData(encodedAudioChunk: EncodedAudioChunk) {
let arrayBuffer = new ArrayBuffer(encodedAudioChunk.byteLength);
encodedAudioChunk.copyTo(arrayBuffer);
if (this.#ptt) {
let packet = new AudioPacket(
new Uint8Array(arrayBuffer),
{
frequency: this.#frequency,
modulation: this.#modulation,
},
getApp().getAudioManager().getGuid()
);
getApp().getAudioManager().send(packet.getArray());
}
}
}

View File

@ -1,83 +0,0 @@
import { AudioSink } from "./audiosink";
import { AudioPacket } from "./audiopacket";
import { getApp } from "../olympusapp";
export class SRSRadio extends AudioSink {
#encoder: AudioEncoder;
#node: MediaStreamAudioDestinationNode;
#audioTrackProcessor: any; // TODO can we have typings?
#gainNode: GainNode;
#setting = {
frequency: 251000000,
modulation: 0,
ptt: false,
tuned: false,
volume: 0.5,
};
constructor() {
super();
/* A gain node is used because it allows to connect multiple inputs */
this.#gainNode = getApp().getAudioManager().getAudioContext().createGain();
this.#node = getApp().getAudioManager().getAudioContext().createMediaStreamDestination();
this.#node.channelCount = 1;
this.#encoder = new AudioEncoder({
output: (data) => this.#handleEncodedData(data),
error: (e) => {console.log(e);},
});
this.#encoder.configure({
codec: 'opus',
numberOfChannels: 1,
sampleRate: 16000,
//@ts-ignore // TODO why is it giving error?
opus: {
frameDuration: 40000,
},
bitrateMode: "constant"
});
//@ts-ignore
this.#audioTrackProcessor = new MediaStreamTrackProcessor({
track: this.#node.stream.getAudioTracks()[0],
});
this.#audioTrackProcessor.readable.pipeTo(
new WritableStream({
write: (arrayBuffer) => this.#handleRawData(arrayBuffer),
})
);
this.#gainNode.connect(this.#node);
}
getSetting() {
return this.#setting;
}
setSetting(setting) {
this.#setting = setting;
document.dispatchEvent(new CustomEvent("radiosUpdated"));
}
getNode() {
return this.#gainNode;
}
#handleEncodedData(audioBuffer) {
let arrayBuffer = new ArrayBuffer(audioBuffer.byteLength);
audioBuffer.copyTo(arrayBuffer);
if (this.#setting.ptt) {
let packet = new AudioPacket(new Uint8Array(arrayBuffer), this.#setting, getApp().getAudioManager().getGuid());
getApp().getAudioManager().send(packet.getArray());
}
}
#handleRawData(audioData) {
this.#encoder.encode(audioData);
audioData.close();
}
}

View File

@ -0,0 +1,34 @@
import { AudioSink } from "./audiosink";
import { AudioPacket } from "./audiopacket";
import { getApp } from "../olympusapp";
import { Unit } from "../unit/unit";
export class UnitSink extends AudioSink {
#unit: Unit;
constructor(unit: Unit) {
super();
this.#unit = unit;
this.setName(`${unit.getUnitName()} - ${unit.getName()}`);
}
getUnit() {
return this.#unit;
}
handleEncodedData(encodedAudioChunk: EncodedAudioChunk) {
let arrayBuffer = new ArrayBuffer(encodedAudioChunk.byteLength);
encodedAudioChunk.copyTo(arrayBuffer);
let packet = new AudioPacket(
new Uint8Array(arrayBuffer),
{
frequency: 243000000,
modulation: 255, // HOPEFULLY this will never be used by SRS, indicates "loudspeaker" mode
},
getApp().getAudioManager().getGuid()
);
getApp().getAudioManager().send(packet.getArray());
}
}

View File

@ -27,7 +27,7 @@ interface CustomEventMap {
showUnitContextMenu: CustomEvent<any>;
hideUnitContextMenu: CustomEvent<any>;
audioSourcesUpdated: CustomEvent<any>;
radiosUpdated: CustomEvent<any>;
audioSinksUpdated: CustomEvent<any>;
}
declare global {

View File

@ -9,6 +9,7 @@ export const EventsContext = createContext({
setOptionsMenuVisible: (e: boolean) => {},
setAirbaseMenuVisible: (e: boolean) => {},
setRadioMenuVisible: (e: boolean) => {},
setAudioMenuVisible: (e: boolean) => {},
toggleMainMenuVisible: () => {},
toggleSpawnMenuVisible: () => {},
toggleUnitControlMenuVisible: () => {},
@ -17,6 +18,7 @@ export const EventsContext = createContext({
toggleOptionsMenuVisible: () => {},
toggleAirbaseMenuVisible: () => {},
toggleRadioMenuVisible: () => {},
toggleAudioMenuVisible: () => {},
});
export const EventsProvider = EventsContext.Provider;

View File

@ -1,5 +1,6 @@
import { LatLng } from "leaflet";
import { Coalition, Context } from "./types/types";
import { AudioSink } from "./audio/audiosink";
class Airbase {}
@ -292,17 +293,5 @@ export interface ServerStatus {
paused: boolean;
}
export interface SRSRadioSetting {
frequency: number;
modulation: number;
volume: number;
ptt: boolean;
tuned: boolean;
}
export interface AudioSourceSetting {
filename: string;
playing: boolean;
connectedTo: string;
}

View File

@ -10,6 +10,7 @@ export const StateContext = createContext({
optionsMenuVisible: false,
airbaseMenuVisible: false,
radioMenuVisible: false,
audioMenuVisible: false,
mapHiddenTypes: MAP_HIDDEN_TYPES_DEFAULTS,
mapOptions: MAP_OPTIONS_DEFAULTS,
mapSources: [] as string[],

View File

@ -0,0 +1,75 @@
import React, { useEffect, useState } from "react";
import { Menu } from "./components/menu";
import { getApp } from "../../olympusapp";
import { FaQuestionCircle } from "react-icons/fa";
import { AudioSourcePanel } from "./components/audiosourcepanel";
import { AudioSource } from "../../audio/audiosource";
export function AudioMenu(props: { open: boolean; onClose: () => void; children?: JSX.Element | JSX.Element[] }) {
const [sources, setSources] = useState([] as AudioSource[]);
useEffect(() => {
/* Force a rerender */
document.addEventListener("audioSourcesUpdated", () => {
setSources(
getApp()
?.getAudioManager()
.getSources()
.map((source) => source)
);
});
}, []);
return (
<Menu title="Audio sources" open={props.open} showBackButton={false} onClose={props.onClose}>
<div className="p-4 text-sm text-gray-400">The audio source panel allows you to add and manage audio sources.</div>
<div className="mx-6 flex rounded-lg bg-olympus-400 p-4 text-sm">
<div>
<FaQuestionCircle className="my-4 ml-2 mr-6 text-gray-400" />
</div>
<div className="flex flex-col gap-1">
<div className="text-gray-100">Use the controls to apply effects and start/stop the playback of an audio source.</div>
<div className="text-gray-400">Sources can be connected to your radios, or attached to a unit to be played on loudspeakers.</div>
</div>
</div>
<div
className={`
flex flex-col gap-2 p-5 font-normal text-gray-800
dark:text-white
`}
>
<>
{sources
.map((source) => {
return <AudioSourcePanel source={source} />;
})}
</>
<button
type="button"
className={`
mb-2 me-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={() => {
var input = document.createElement("input");
input.type = "file";
input.click();
input.onchange = (e: Event) => {
let target = e.target as HTMLInputElement;
if (target && target.files) {
var file = target.files[0];
getApp().getAudioManager().addFileSource(file);
}
};
}}
>
Add audio source
</button>
</div>
</Menu>
);
}

View File

@ -3,10 +3,12 @@ import { OlStateButton } from "../../components/olstatebutton";
import { faPlay, faRepeat } from "@fortawesome/free-solid-svg-icons";
import { getApp } from "../../../olympusapp";
import { AudioSource } from "../../../audio/audiosource";
import { FaVolumeHigh } from "react-icons/fa6";
import { FaTrash, FaVolumeHigh } from "react-icons/fa6";
import { OlRangeSlider } from "../../components/olrangeslider";
import { FaUnlink } from "react-icons/fa";
import { OlDropdown, OlDropdownItem } from "../../components/oldropdown";
export function AudioSourcePanel(props: { index: number; source: AudioSource }) {
export function AudioSourcePanel(props: { source: AudioSource }) {
return (
<div
className={`
@ -14,36 +16,51 @@ export function AudioSourcePanel(props: { index: number; source: AudioSource })
bg-olympus-200/30 py-3 pl-4 pr-5
`}
>
Source: {props.source.getName()}
<div className="flex gap-4 py-2">
<OlStateButton
checked={false}
icon={faPlay}
onClick={() => {
let sources = getApp().getAudioManager().getSources();
sources[props.index].play();
}}
tooltip="Play file"
></OlStateButton>
<OlRangeSlider
value={50}
onChange={(ev) => {
//let setting = props.setting;
//setting.volume = parseFloat(ev.currentTarget.value) / 100;
//props.updateSetting(setting);
}}
className="my-auto"
/>
<OlStateButton
checked={false}
icon={faRepeat}
onClick={() => {
}}
tooltip="Loop"
></OlStateButton>
<span>{props.source.getName()}</span>
{props.source.getName() != "Microphone" && (
<div className="flex gap-4 py-2">
<OlStateButton
checked={false}
icon={faPlay}
onClick={() => {
props.source.play();
}}
tooltip="Play file"
></OlStateButton>
<OlRangeSlider
value={50}
onChange={(ev) => {
//let setting = props.setting;
//setting.volume = parseFloat(ev.currentTarget.value) / 100;
//props.updateSetting(setting);
}}
className="my-auto"
/>
<OlStateButton checked={false} icon={faRepeat} onClick={() => {}} tooltip="Loop"></OlStateButton>
</div>
)}
<span className="text-sm">Connected to:</span>
<div className="flex flex-col gap-2">
{props.source.getConnectedTo().map((sink) => {
return (
<div className="flex justify-between text-sm">
{sink.getName()}
<FaUnlink></FaUnlink>
</div>
);
})}
<OlDropdown label="Connect to:">
{getApp()
.getAudioManager()
.getSinks()
.filter((sink) => !props.source.getConnectedTo().includes(sink))
.map((sink) => {
return <OlDropdownItem onClick={() => {
props.source.connect(sink);
}}>{sink.getName()}</OlDropdownItem>;
})}
</OlDropdown>
</div>
</div>
);
}

View File

@ -4,11 +4,10 @@ import { FaTrash } from "react-icons/fa6";
import { OlLabelToggle } from "../../components/ollabeltoggle";
import { OlStateButton } from "../../components/olstatebutton";
import { faEarListen, faMicrophoneLines } from "@fortawesome/free-solid-svg-icons";
import { SRSRadio } from "../../../audio/srsradio";
import { SRSRadioSetting } from "../../../interfaces";
import { RadioSink } from "../../../audio/radiosink";
import { getApp } from "../../../olympusapp";
export function RadioPanel(props: { index: number; setting: SRSRadioSetting, onSettingUpdate: (SRSRadioSetting) => void }) {
export function RadioPanel(props: { radio: RadioSink }) {
return (
<div
className={`
@ -17,50 +16,42 @@ export function RadioPanel(props: { index: number; setting: SRSRadioSetting, onS
`}
>
<div className="flex content-center justify-between">
<span className="my-auto">Radio {props.index + 1}</span>
<div className="rounded-md bg-red-800 p-2" onClick={() => {getApp().getAudioManager().removeRadio(props.index);}}>
<span className="my-auto">{props.radio.getName()}</span>
<div className="rounded-md bg-red-800 p-2" onClick={() => {getApp().getAudioManager().removeSink(props.radio);}}>
<FaTrash className={`text-gray-50`}></FaTrash>
</div>
</div>
<OlFrequencyInput
value={props.setting.frequency}
value={props.radio.getFrequency()}
onChange={(value) => {
let setting = props.setting;
setting.frequency = value;
props.onSettingUpdate(setting);
props.radio.setFrequency(value)
}}
/>
<div className="flex flex-row gap-2">
<OlLabelToggle
leftLabel="AM"
rightLabel="FM"
toggled={props.setting.modulation !== 0}
toggled={props.radio.getModulation() !== 0}
onClick={() => {
let setting = props.setting;
setting.modulation = setting.modulation === 1 ? 0 : 1;
props.onSettingUpdate(setting);
props.radio.setModulation(props.radio.getModulation() === 1 ? 0 : 1);
}}
></OlLabelToggle>
<OlStateButton
className="ml-auto"
checked={props.setting.ptt}
checked={props.radio.getPtt()}
icon={faMicrophoneLines}
onClick={() => {
let setting = props.setting;
setting.ptt = !setting.ptt;
props.onSettingUpdate(setting);
props.radio.setPtt(!props.radio.getPtt());
}}
tooltip="Talk on frequency"
></OlStateButton>
<OlStateButton
checked={props.setting.tuned}
checked={props.radio.getTuned()}
icon={faEarListen}
onClick={() => {
let setting = props.setting;
setting.tuned = !setting.tuned;
props.onSettingUpdate(setting);
props.radio.setTuned(!props.radio.getTuned());
}}
tooltip="Tune to radio"
></OlStateButton>

View File

@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from "react";
import { OlRoundStateButton, OlStateButton, OlLockStateButton } from "../components/olstatebutton";
import { faSkull, faCamera, faFlag, faLink, faUnlink, faBars } from "@fortawesome/free-solid-svg-icons";
import { faSkull, faCamera, faFlag, faLink, faUnlink, faBars, faVolumeHigh } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { EventsConsumer } from "../../eventscontext";
import { StateConsumer } from "../../statecontext";
@ -23,12 +23,12 @@ import { FaChevronLeft, FaChevronRight } from "react-icons/fa6";
export function Header() {
const [scrolledLeft, setScrolledLeft] = useState(true);
const [scrolledRight, setScrolledRight] = useState(false);
const [audioEnabled, setAudioEnabled] = useState(false);
/* Initialize the "scroll" position of the element */
var scrollRef = useRef(null);
useEffect(() => {
if (scrollRef.current)
onScroll(scrollRef.current);
if (scrollRef.current) onScroll(scrollRef.current);
});
function onScroll(el) {
@ -54,10 +54,9 @@ export function Header() {
dark:border-gray-800 dark:bg-olympus-900
`}
>
<img
src="images/icon.png"
className={`my-auto h-10 w-10 rounded-md p-0`}
></img>
<img src="images/icon.png" className={`
my-auto h-10 w-10 rounded-md p-0
`}></img>
{!scrolledLeft && (
<FaChevronLeft
className={`
@ -111,8 +110,21 @@ export function Header() {
</div>
</div>
</div>
<div>
<div
className={`
flex h-fit flex-row items-center justify-start gap-1
`}
>
<OlLockStateButton checked={false} onClick={() => {}} tooltip="Lock/unlock protected units (from scripted mission)" />
<OlRoundStateButton
checked={audioEnabled}
onClick={() => {
audioEnabled ? getApp().getAudioManager().stop() : getApp().getAudioManager().start();
setAudioEnabled(!audioEnabled);
}}
tooltip="Enable/disable audio and radio backend"
icon={faVolumeHigh}
/>
</div>
<div className={`h-8 w-0 border-l-[2px] border-gray-700`}></div>
<div

View File

@ -4,20 +4,20 @@ import { getApp } from "../../olympusapp";
import { OlToggle } from "../components/oltoggle";
import { RadioPanel } from "./components/radiopanel";
import { FaQuestionCircle } from "react-icons/fa";
import { SRSRadioSetting } from "../../interfaces";
import { RadioSink } from "../../audio/radiosink";
export function RadioMenu(props: { open: boolean; onClose: () => void; children?: JSX.Element | JSX.Element[] }) {
const [radioEnabled, setRadioEnabled] = useState(false);
const [radioSettings, setRadioSettings] = useState([] as SRSRadioSetting[]);
const [radios, setRadios] = useState([] as RadioSink[]);
useEffect(() => {
/* Force a rerender */
document.addEventListener("radiosUpdated", () => {
setRadioSettings(
document.addEventListener("audioSinksUpdated", () => {
setRadios(
getApp()
?.getAudioManager()
.getRadios()
.map((radio) => radio.getSetting())
.getSinks()
.filter((sink) => sink instanceof RadioSink)
.map((radio) => radio)
);
});
}, []);
@ -40,28 +40,10 @@ export function RadioMenu(props: { open: boolean; onClose: () => void; children?
dark:text-white
`}
>
<div className="flex justify-between">
<span>Enable radio:</span>
<OlToggle
toggled={radioEnabled}
onClick={() => {
radioEnabled ? getApp().getAudioManager().stop() : getApp().getAudioManager().start();
setRadioEnabled(!radioEnabled);
}}
/>
</div>
{radioEnabled && radioSettings.map((setting, idx) => {
return (
<RadioPanel
index={idx}
setting={setting}
onSettingUpdate={(setting) => {
getApp().getAudioManager().getRadios()[idx].setSetting(setting);
}}
></RadioPanel>
);
{radios.map((radio) => {
return <RadioPanel radio={radio}></RadioPanel>;
})}
{radioEnabled && radioSettings.length < 10 && (
{radios.length < 10 && (
<button
type="button"
className={`
@ -80,28 +62,3 @@ export function RadioMenu(props: { open: boolean; onClose: () => void; children?
</Menu>
);
}
/*
{refreshSources >= 0 &&
getApp()
?.getAudioManager()
.getSources()
.map((source, idx) => {
return <AudioSourcePanel index={idx} source={source} />;
})}
<button
onClick={() => {
var input = document.createElement("input");
input.type = "file";
input.click();
input.onchange = (e: Event) => {
let target = e.target as HTMLInputElement;
if (target && target.files) {
var file = target.files[0];
getApp().getAudioManager().addFileSource(file);
}
};
}}
>
Add audio source
</button> */

View File

@ -1,9 +1,10 @@
import React, { useState } from "react";
import { OlStateButton } from "../components/olstatebutton";
import { faGamepad, faRuler, faPencil, faEllipsisV, faCog, faQuestionCircle, faPlusSquare, faMagnifyingGlass, faPlaneDeparture, faRadio } from "@fortawesome/free-solid-svg-icons";
import { faGamepad, faRuler, faPencil, faEllipsisV, faCog, faQuestionCircle, faPlusSquare, faMagnifyingGlass, faPlaneDeparture, faRadio, faVolumeHigh } from "@fortawesome/free-solid-svg-icons";
import { EventsConsumer } from "../../eventscontext";
import { StateConsumer } from "../../statecontext";
import { IDLE } from "../../constants/constants";
import { faSpeakerDeck } from "@fortawesome/free-brands-svg-icons";
export function SideBar() {
return (
@ -64,6 +65,12 @@ export function SideBar() {
icon={faRadio}
tooltip="Hide/show radio menu"
></OlStateButton>
<OlStateButton
onClick={events.toggleAudioMenuVisible}
checked={appState.audioMenuVisible}
icon={faVolumeHigh}
tooltip="Hide/show audio menu"
></OlStateButton>
</div>
</div>
<div className="flex w-16 flex-wrap content-end justify-center p-4">

View File

@ -23,6 +23,7 @@ import { MapContextMenu } from "./contextmenus/mapcontextmenu";
import { AirbaseMenu } from "./panels/airbasemenu";
import { Airbase } from "../mission/airbase";
import { RadioMenu } from "./panels/radiomenu";
import { AudioMenu } from "./panels/audiomenu";
export type OlympusUIState = {
mainMenuVisible: boolean;
@ -44,6 +45,7 @@ export function UI() {
const [measureMenuVisible, setMeasureMenuVisible] = useState(false);
const [drawingMenuVisible, setDrawingMenuVisible] = useState(false);
const [radioMenuVisible, setRadioMenuVisible] = useState(false);
const [audioMenuVisible, setAudioMenuVisible] = useState(false);
const [optionsMenuVisible, setOptionsMenuVisible] = useState(false);
const [airbaseMenuVisible, setAirbaseMenuVisible] = useState(false);
const [mapHiddenTypes, setMapHiddenTypes] = useState(MAP_HIDDEN_TYPES_DEFAULTS);
@ -100,6 +102,7 @@ export function UI() {
setOptionsMenuVisible(false);
setAirbaseMenuVisible(false);
setRadioMenuVisible(false);
setAudioMenuVisible(false);
}
function checkPassword(password: string) {
@ -157,6 +160,7 @@ export function UI() {
optionsMenuVisible: optionsMenuVisible,
airbaseMenuVisible: airbaseMenuVisible,
radioMenuVisible: radioMenuVisible,
audioMenuVisible: audioMenuVisible,
mapOptions: mapOptions,
mapHiddenTypes: mapHiddenTypes,
mapSources: mapSources,
@ -174,6 +178,7 @@ export function UI() {
setOptionsMenuVisible: setOptionsMenuVisible,
setAirbaseMenuVisible: setAirbaseMenuVisible,
setRadioMenuVisible: setRadioMenuVisible,
setAudioMenuVisible: setAudioMenuVisible,
toggleMainMenuVisible: () => {
hideAllMenus();
setMainMenuVisible(!mainMenuVisible);
@ -206,6 +211,10 @@ export function UI() {
hideAllMenus();
setRadioMenuVisible(!radioMenuVisible);
},
toggleAudioMenuVisible: () => {
hideAllMenus();
setAudioMenuVisible(!audioMenuVisible);
},
}}
>
<Header />
@ -241,6 +250,7 @@ export function UI() {
<DrawingMenu open={drawingMenuVisible} onClose={() => setDrawingMenuVisible(false)} />
<AirbaseMenu open={airbaseMenuVisible} onClose={() => setAirbaseMenuVisible(false)} airbase={airbase}/>
<RadioMenu open={radioMenuVisible} onClose={() => setRadioMenuVisible(false)} />
<AudioMenu open={audioMenuVisible} onClose={() => setAudioMenuVisible(false)} />
<MiniMapPanel />
<ControlsPanel />

View File

@ -74,6 +74,7 @@ import {
faPeopleGroup,
faQuestionCircle,
faRoute,
faVolumeHigh,
faXmarksLines,
} from "@fortawesome/free-solid-svg-icons";
import { FaXmarksLines } from "react-icons/fa6";
@ -850,6 +851,19 @@ export abstract class Unit extends CustomMarker {
}
);
contextActionSet.addContextAction(
this,
"speaker",
"Make audio source",
"Make this unit an audio source (loudspeakers)",
faVolumeHigh,
null,
(units: Unit[], _1, _2) => {
units.forEach((unit) => getApp().getAudioManager().addUnitSink(unit));
},
{ executeImmediately: true }
);
contextActionSet.addDefaultContextAction(
this,
"default",

View File

@ -3,6 +3,7 @@ const { OpusEncoder } = require("@discordjs/opus");
const encoder = new OpusEncoder(16000, 1);
var net = require("net");
var bufferString = "";
const SRS_VERSION = "2.1.0.10";
@ -13,43 +14,43 @@ enum MessageType {
settings,
}
function fromBytes(array) {
let res = 0;
for (let i = 0; i < array.length; i++) {
res = res << 8;
res += array[array.length - i - 1];
}
return res;
}
function getBytes(value, length) {
let res: number[] = [];
for (let i = 0; i < length; i++) {
res.push(value & 255);
value = value >> 8;
}
return res;
}
export class SRSHandler {
ws: any;
tcp = new net.Socket();
udp = require("dgram").createSocket("udp4");
data = JSON.parse(JSON.stringify(defaultSRSData));
syncInterval: any;
packetQueue = [];
clients = [];
SRSPort = 0;
constructor(ws, SRSPort) {
this.data.Name = `Olympus${globalIndex}`;
this.SRSPort = SRSPort;
globalIndex += 1;
/* Websocket */
this.ws = ws;
this.ws.on("error", console.error);
this.ws.on("message", (data) => {
switch (data[0]) {
case MessageType.audio:
let audioBuffer = data.slice(1);
this.packetQueue.push(audioBuffer);
this.udp.send(audioBuffer, SRSPort, "localhost", (error) => {
if (error)
console.log(`Error sending data to SRS server: ${error}`);
});
break;
case MessageType.settings:
let message = JSON.parse(data.slice(1));
this.data.ClientGuid = message.guid;
this.data.Coalition = message.coalition;
message.settings.forEach((setting, idx) => {
this.data.RadioInfo.radios[idx].freq = setting.frequency;
this.data.RadioInfo.radios[idx].modulation = setting.modulation;
});
break;
default:
break;
}
this.decodeData(data);
});
this.ws.on("close", () => {
this.tcp.end();
@ -81,6 +82,20 @@ export class SRSHandler {
}, 1000);
});
this.tcp.on("data", (data) => {
bufferString += data.toString();
while (bufferString.includes("\n")) {
try {
let message = JSON.parse(bufferString.split("\n")[0]);
bufferString = bufferString.slice(bufferString.indexOf("\n") + 1);
if (message.Clients !== undefined)
this.clients = message.Clients;
} catch (e) {
console.log(e);
}
}
});
/* UDP */
this.udp.on("listening", () => {
console.log(`Listening to SRS Server on UDP port ${SRSPort}`);
@ -90,4 +105,52 @@ export class SRSHandler {
if (this.ws && message.length > 22) this.ws.send(message);
});
}
decodeData(data){
switch (data[0]) {
case MessageType.audio:
let packetUint8Array = new Uint8Array(data.slice(1));
let audioLength = fromBytes(packetUint8Array.slice(2, 4));
let frequenciesLength = fromBytes(packetUint8Array.slice(4, 6));
let modulation = fromBytes(packetUint8Array.slice(6 + audioLength + 8, 6 + audioLength + 8 + 1));
let offset = 6 + audioLength + frequenciesLength;
if (modulation == 255) {
packetUint8Array[6 + audioLength + 8] = 2;
this.clients.forEach((client) => {
getBytes(client.RadioInfo.unitId, 4).forEach((value, idx) => {
packetUint8Array[offset + idx] = value;
});
var dst = new ArrayBuffer(packetUint8Array.byteLength);
let newBuffer = new Uint8Array(dst);
newBuffer.set(new Uint8Array(packetUint8Array));
this.udp.send(newBuffer, this.SRSPort, "localhost", (error) => {
if (error)
console.log(`Error sending data to SRS server: ${error}`);
})
})
} else {
this.udp.send(packetUint8Array, this.SRSPort, "localhost", (error) => {
if (error)
console.log(`Error sending data to SRS server: ${error}`);
});
}
break;
case MessageType.settings:
let message = JSON.parse(data.slice(1));
this.data.ClientGuid = message.guid;
this.data.Coalition = message.coalition;
message.settings.forEach((setting, idx) => {
this.data.RadioInfo.radios[idx].freq = setting.frequency;
this.data.RadioInfo.radios[idx].modulation = setting.modulation;
});
break;
default:
break;
}
}
}