diff --git a/frontend/react/src/audio/audiomanager.ts b/frontend/react/src/audio/audiomanager.ts index 95ef049e..7b05cb30 100644 --- a/frontend/react/src/audio/audiomanager.ts +++ b/frontend/react/src/audio/audiomanager.ts @@ -149,6 +149,12 @@ 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; diff --git a/frontend/react/src/audio/audiopacket.ts b/frontend/react/src/audio/audiopacket.ts index d4688c1d..398d1004 100644 --- a/frontend/react/src/audio/audiopacket.ts +++ b/frontend/react/src/audio/audiopacket.ts @@ -23,7 +23,7 @@ var packetID = 0; export class AudioPacket { #packet: Uint8Array; - constructor(data, settings, guid) { + constructor(data, settings, guid, lat?, lng?, alt?) { let header: number[] = [0, 0, 0, 0, 0, 0]; let encFrequency: number[] = [...doubleToByteArray(settings.frequency)]; @@ -48,6 +48,10 @@ export class AudioPacket { [...Buffer.from(guid, "utf-8")] ); + if (lat !== undefined && lng !== undefined && alt !== undefined) { + packet.concat([...doubleToByteArray(lat)], [...doubleToByteArray(lng)], [...doubleToByteArray(alt)]); + } + let encPacketLen = getBytes(packet.length, 2); packet[0] = encPacketLen[0]; packet[1] = encPacketLen[1]; diff --git a/frontend/react/src/audio/audiosource.ts b/frontend/react/src/audio/audiosource.ts index 2fbdf47f..12256726 100644 --- a/frontend/react/src/audio/audiosource.ts +++ b/frontend/react/src/audio/audiosource.ts @@ -1,9 +1,18 @@ +import { getApp } from "../olympusapp"; import { AudioSink } from "./audiosink"; +import { WebAudioPeakMeter } from "web-audio-peak-meter"; export abstract class AudioSource { #connectedTo: AudioSink[] = []; #name = ""; - #playing = false; + #meter: WebAudioPeakMeter; + #volume: number = 1.0; + #gainNode: GainNode; + + constructor() { + this.#gainNode = getApp().getAudioManager().getAudioContext().createGain(); + this.#meter = new WebAudioPeakMeter(this.#gainNode, document.createElement('div')); + } connect(sink: AudioSink) { this.getNode().connect(sink.getNode()); @@ -18,7 +27,7 @@ export abstract class AudioSource { } else { this.getNode().disconnect(); } - + document.dispatchEvent(new CustomEvent("audioSourcesUpdated")); } @@ -34,6 +43,23 @@ export abstract class AudioSource { return this.#connectedTo; } + setVolume(volume) { + this.#volume = volume; + this.#gainNode.gain.exponentialRampToValueAtTime(volume, getApp().getAudioManager().getAudioContext().currentTime + 0.02); + document.dispatchEvent(new CustomEvent("audioSourcesUpdated")); + } + + getVolume() { + return this.#volume; + } + + getMeter() { + return this.#meter; + } + + getNode() { + return this.#gainNode; + } + abstract play(): void; - abstract getNode(): AudioNode; } diff --git a/frontend/react/src/audio/filesource.ts b/frontend/react/src/audio/filesource.ts index f844144a..7bdf006a 100644 --- a/frontend/react/src/audio/filesource.ts +++ b/frontend/react/src/audio/filesource.ts @@ -3,7 +3,6 @@ import { getApp } from "../olympusapp"; import {WebAudioPeakMeter} from 'web-audio-peak-meter'; export class FileSource extends AudioSource { - #gainNode: GainNode; #file: File | null = null; #source: AudioBufferSourceNode; #duration: number = 0; @@ -14,8 +13,6 @@ export class FileSource extends AudioSource { #audioBuffer: AudioBuffer; #restartTimeout: any; #looping = false; - #meter: WebAudioPeakMeter; - #volume: number = 1.0; constructor(file) { super(); @@ -23,11 +20,10 @@ export class FileSource extends AudioSource { this.setName(this.#file?.name ?? "N/A"); - this.#gainNode = getApp().getAudioManager().getAudioContext().createGain(); - if (!this.#file) { return; } + var reader = new FileReader(); reader.onload = (e) => { var contents = e.target?.result; @@ -44,18 +40,12 @@ export class FileSource extends AudioSource { reader.readAsArrayBuffer(this.#file); } - getNode() { - return this.#gainNode; - } - play() { this.#source = getApp().getAudioManager().getAudioContext().createBufferSource(); this.#source.buffer = this.#audioBuffer; - this.#source.connect(this.#gainNode); + this.#source.connect(this.getNode()); this.#source.loop = this.#looping; - this.#meter = new WebAudioPeakMeter(this.#gainNode, document.createElement('div')); - this.#source.start(0, this.#currentPosition); this.#playing = true; const now = Date.now() / 1000; @@ -79,6 +69,7 @@ export class FileSource extends AudioSource { stop() { this.#source.stop(); + this.#source.disconnect(); this.#playing = false; const now = Date.now() / 1000; @@ -88,10 +79,6 @@ export class FileSource extends AudioSource { document.dispatchEvent(new CustomEvent("audioSourcesUpdated")); } - setGain(gain) { - this.#gainNode.gain.setValueAtTime(gain, getApp().getAudioManager().getAudioContext().currentTime); - } - getPlaying() { return this.#playing; } @@ -123,17 +110,4 @@ export class FileSource extends AudioSource { getLooping() { return this.#looping; } - - getMeter() { - return this.#meter; - } - - setVolume(volume) { - this.#volume = volume; - this.#gainNode.gain.exponentialRampToValueAtTime(volume, getApp().getAudioManager().getAudioContext().currentTime + 0.02); - } - - getVolume() { - return this.#volume; - } } diff --git a/frontend/react/src/audio/microphonesource.ts b/frontend/react/src/audio/microphonesource.ts index 209ffcd2..05610380 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: AudioNode; + #node: MediaStreamAudioSourceNode; constructor() { super(); @@ -14,14 +14,12 @@ export class MicrophoneSource extends AudioSource { const microphone = await navigator.mediaDevices.getUserMedia({ audio: true }); if (getApp().getAudioManager().getAudioContext()) { this.#node = getApp().getAudioManager().getAudioContext().createMediaStreamSource(microphone); + + this.#node.connect(this.getNode()); } } - getNode() { - return this.#node; - } - play() { - // TODO, now is always on + document.dispatchEvent(new CustomEvent("audioSourcesUpdated")); } } diff --git a/frontend/react/src/audio/unitsink.ts b/frontend/react/src/audio/unitsink.ts index 9390f9c0..7992aed4 100644 --- a/frontend/react/src/audio/unitsink.ts +++ b/frontend/react/src/audio/unitsink.ts @@ -27,7 +27,10 @@ export class UnitSink extends AudioSink { frequency: 243000000, modulation: 255, // HOPEFULLY this will never be used by SRS, indicates "loudspeaker" mode }, - getApp().getAudioManager().getGuid() + getApp().getAudioManager().getGuid(), + this.#unit.getPosition().lat, + this.#unit.getPosition().lng, + this.#unit.getPosition().alt ); getApp().getAudioManager().send(packet.getArray()); } diff --git a/frontend/react/src/olympusapp.ts b/frontend/react/src/olympusapp.ts index eaf90099..43e3b7f9 100644 --- a/frontend/react/src/olympusapp.ts +++ b/frontend/react/src/olympusapp.ts @@ -29,7 +29,7 @@ import { AudioManager } from "./audio/audiomanager"; export var VERSION = "{{OLYMPUS_VERSION_NUMBER}}"; export var IP = window.location.toString(); -export var connectedToServer = true; // Temporary +export var connectedToServer = true; // TODO Temporary export class OlympusApp { /* Global data */ diff --git a/frontend/react/src/ui/panels/audiomenu.tsx b/frontend/react/src/ui/panels/audiomenu.tsx index dccd3e09..834de93c 100644 --- a/frontend/react/src/ui/panels/audiomenu.tsx +++ b/frontend/react/src/ui/panels/audiomenu.tsx @@ -2,7 +2,7 @@ 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 { AudioSourcePanel } from "./components/sourcepanel"; import { AudioSource } from "../../audio/audiosource"; export function AudioMenu(props: { open: boolean; onClose: () => void; children?: JSX.Element | JSX.Element[] }) { diff --git a/frontend/react/src/ui/panels/components/audiosourcepanel.tsx b/frontend/react/src/ui/panels/components/audiosourcepanel.tsx deleted file mode 100644 index 13bab676..00000000 --- a/frontend/react/src/ui/panels/components/audiosourcepanel.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { OlStateButton } from "../../components/olstatebutton"; -import { faPause, faPlay, faRepeat, faStop } from "@fortawesome/free-solid-svg-icons"; -import { getApp } from "../../../olympusapp"; -import { AudioSource } from "../../../audio/audiosource"; -import { FaTrash, FaVolumeHigh } from "react-icons/fa6"; -import { OlRangeSlider } from "../../components/olrangeslider"; -import { FaUnlink } from "react-icons/fa"; -import { OlDropdown, OlDropdownItem } from "../../components/oldropdown"; -import { FileSource } from "../../../audio/filesource"; - -export function AudioSourcePanel(props: { source: AudioSource }) { - const [meterLevel, setMeterLevel] = useState(0); - - useEffect(() => { - setInterval(() => { - // TODO apply to all sources - if (props.source instanceof FileSource) { - setMeterLevel(props.source.getMeter().getPeaks().current[0]); - } - }, 50); - }, []); - - return ( -
- {props.source.getName()} - {props.source instanceof FileSource && ( -
-
- { - if (props.source instanceof FileSource) props.source.getPlaying() ? props.source.stop() : props.source.play(); - }} - tooltip="Play file" - > - { - if (props.source instanceof FileSource) props.source.setCurrentPosition(parseFloat(ev.currentTarget.value)); - }} - className="my-auto" - /> - { - if (props.source instanceof FileSource) props.source.setLooping(!props.source.getLooping()); - }} - tooltip="Loop" - > -
-
-
- -
-
-
-
-
- { - if (props.source instanceof FileSource) props.source.setVolume(parseFloat(ev.currentTarget.value) / 100); - }} - className="absolute top-[18px]" - /> -
-
- {Math.round(props.source.getVolume() * 100)} -
-
-
- )} - Connected to: -
- {props.source.getConnectedTo().map((sink) => { - return ( -
- {sink.getName()} - props.source.disconnect(sink)}> -
- ); - })} - - {getApp() - .getAudioManager() - .getSinks() - .filter((sink) => !props.source.getConnectedTo().includes(sink)) - .map((sink) => { - return ( - { - props.source.connect(sink); - }} - > - {sink.getName()} - - ); - })} - -
-
- ); -} diff --git a/frontend/react/src/ui/panels/components/radiopanel.tsx b/frontend/react/src/ui/panels/components/radiopanel.tsx index 9b471355..3209e686 100644 --- a/frontend/react/src/ui/panels/components/radiopanel.tsx +++ b/frontend/react/src/ui/panels/components/radiopanel.tsx @@ -17,7 +17,7 @@ export function RadioPanel(props: { radio: RadioSink }) { >
{props.radio.getName()} -
{getApp().getAudioManager().removeSink(props.radio);}}> +
{getApp().getAudioManager().removeSink(props.radio);}}>
diff --git a/frontend/react/src/ui/panels/components/sourcepanel.tsx b/frontend/react/src/ui/panels/components/sourcepanel.tsx new file mode 100644 index 00000000..de5e818e --- /dev/null +++ b/frontend/react/src/ui/panels/components/sourcepanel.tsx @@ -0,0 +1,141 @@ +import React, { useEffect, useState } from "react"; +import { OlStateButton } from "../../components/olstatebutton"; +import { faPause, faPlay, faRepeat, faStop } from "@fortawesome/free-solid-svg-icons"; +import { getApp } from "../../../olympusapp"; +import { AudioSource } from "../../../audio/audiosource"; +import { FaArrowRight, FaTrash, FaVolumeHigh } from "react-icons/fa6"; +import { OlRangeSlider } from "../../components/olrangeslider"; +import { FaUnlink } from "react-icons/fa"; +import { OlDropdown, OlDropdownItem } from "../../components/oldropdown"; +import { FileSource } from "../../../audio/filesource"; +import { MicrophoneSource } from "../../../audio/microphonesource"; + +export function AudioSourcePanel(props: { source: AudioSource }) { + const [meterLevel, setMeterLevel] = useState(0); + + useEffect(() => { + setInterval(() => { + setMeterLevel(props.source.getMeter().getPeaks().current[0]); + }, 50); + }, []); + + let availabileSinks = getApp() + .getAudioManager() + .getSinks() + .filter((sink) => !props.source.getConnectedTo().includes(sink)); + + return ( +
+
+ {props.source.getName()} + {!(props.source instanceof MicrophoneSource) && ( +
{ + getApp().getAudioManager().removeSource(props.source); + }} + > + +
+ )} +
+ +
+ {props.source instanceof FileSource && ( +
+ { + if (props.source instanceof FileSource) props.source.getPlaying() ? props.source.stop() : props.source.play(); + }} + tooltip="Play file" + > + 0 ? (props.source.getCurrentPosition() / props.source.getDuration()) * 100 : 0} + onChange={(ev) => { + if (props.source instanceof FileSource) props.source.setCurrentPosition(parseFloat(ev.currentTarget.value)); + }} + className="my-auto" + /> + { + if (props.source instanceof FileSource) props.source.setLooping(!props.source.getLooping()); + }} + tooltip="Loop" + > +
+ )} +
+
+ +
+
+
+
+
+ { + props.source.setVolume(parseFloat(ev.currentTarget.value) / 100); + }} + className="absolute top-[18px]" + /> +
+
+ {Math.round(props.source.getVolume() * 100)} +
+
+
+ + Connected to: +
+ {props.source.getConnectedTo().map((sink) => { + return ( +
+ + {sink.getName()} + props.source.disconnect(sink)}> +
+ ); + })} +
+ {availabileSinks.length > 0 && ( + + {availabileSinks.map((sink) => { + return ( + { + props.source.connect(sink); + }} + > + {sink.getName()} + + ); + })} + + )} +
+ ); +} diff --git a/frontend/react/src/ui/ui.tsx b/frontend/react/src/ui/ui.tsx index 143b03de..b3960178 100644 --- a/frontend/react/src/ui/ui.tsx +++ b/frontend/react/src/ui/ui.tsx @@ -134,14 +134,6 @@ export function UI() { setLoginModalVisible(false); } - /* Temporary during devel */ - //useEffect(() => { - // window.setTimeout(() => { - // checkPassword("admin"); - // connect("devel"); - // }, 1000) - //}, []) - return (
{ + return frequenciesData.concat([...doubleToByteArray(data.frequency)], [data.modulation], [data.encryption]); + }) + + // Encode unitID, packetID, hops + let encUnitID: number[] = integerToByteArray(this.#unitID, 4); + let encPacketID: number[] = integerToByteArray(this.#packetID, 8); + let encHops: number[] = [this.#hops]; + + // Assemble packet + let encodedData: number[] = ([] as number[]).concat( + header, + [...this.#audioData], + frequenciesData, + encUnitID, + encPacketID, + encHops, + [...Buffer.from(this.#transmissionGUID, "utf-8")], + [...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]; + encodedData[1] = encPacketLen[1]; + + let encAudioLen = integerToByteArray(this.#audioData.length, 2); + encodedData[2] = encAudioLen[0]; + encodedData[3] = encAudioLen[1]; + + let frequencyAudioLen = integerToByteArray(frequenciesData.length, 2); + encodedData[4] = frequencyAudioLen[0]; + encodedData[5] = frequencyAudioLen[1]; + + this.#encodedData = new Uint8Array([0].concat(encodedData)); + } +} diff --git a/frontend/server/src/audio/srshandler.ts b/frontend/server/src/audio/srshandler.ts index 577a6e29..21d81cb4 100644 --- a/frontend/server/src/audio/srshandler.ts +++ b/frontend/server/src/audio/srshandler.ts @@ -1,7 +1,13 @@ import { defaultSRSData } from "./defaultdata"; + const { OpusEncoder } = require("@discordjs/opus"); const encoder = new OpusEncoder(16000, 1); +let decoder = null; +import('opus-decoder').then((res) => { + decoder = new res.OpusDecoder(); +}); + var net = require("net"); var bufferString = ""; @@ -117,6 +123,7 @@ export class SRSHandler { let offset = 6 + audioLength + frequenciesLength; if (modulation == 255) { + packetUint8Array = packetUint8Array.slice(0, -24) // Remove position data packetUint8Array[6 + audioLength + 8] = 2; this.clients.forEach((client) => { getBytes(client.RadioInfo.unitId, 4).forEach((value, idx) => { diff --git a/frontend/server/src/utils.ts b/frontend/server/src/utils.ts new file mode 100644 index 00000000..da095138 --- /dev/null +++ b/frontend/server/src/utils.ts @@ -0,0 +1,30 @@ +export function byteArrayToInteger(array) { + let res = 0; + for (let i = 0; i < array.length; i++) { + res = res << 8; + res += array[array.length - i - 1]; + } + return res; +} + +export function integerToByteArray(value, length) { + let res: number[] = []; + for (let i = 0; i < length; i++) { + res.push(value & 255); + value = value >> 8; + } + return res; +} + +export function doubleToByteArray(number) { + var buffer = new ArrayBuffer(8); // JS numbers are 8 bytes long, or 64 bits + var longNum = new Float64Array(buffer); // so equivalent to Float64 + + longNum[0] = number; + + return Array.from(new Uint8Array(buffer)); +} + +export function byteArrayToDouble(array) { + return new DataView(array.reverse().buffer).getFloat64(0); +} diff --git a/frontend/server/tsconfig.json b/frontend/server/tsconfig.json index 24dfd8c7..e5a37c12 100644 --- a/frontend/server/tsconfig.json +++ b/frontend/server/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "outDir": "./build", "allowJs": true, - "target": "es5", + "target": "ES2023", "module": "Node16", "moduleResolution": "Node16" },