mirror of
https://github.com/Pax1601/DCSOlympus.git
synced 2025-10-29 16:56:34 +00:00
394 lines
13 KiB
TypeScript
394 lines
13 KiB
TypeScript
import { AudioMessageType, BLUE_COMMANDER, GAME_MASTER, OlympusState, RED_COMMANDER } from "../constants/constants";
|
|
import { MicrophoneSource } from "./microphonesource";
|
|
import { RadioSink } from "./radiosink";
|
|
import { getApp } from "../olympusapp";
|
|
import { makeID } from "../other/utils";
|
|
import { FileSource } from "./filesource";
|
|
import { AudioSource } from "./audiosource";
|
|
import { Buffer } from "buffer";
|
|
import { AudioSink } from "./audiosink";
|
|
import { Unit } from "../unit/unit";
|
|
import { UnitSink } from "./unitsink";
|
|
import { AudioPacket, MessageType } from "./audiopacket";
|
|
import {
|
|
AudioManagerCoalitionChangedEvent,
|
|
AudioManagerDevicesChangedEvent,
|
|
AudioManagerInputChangedEvent,
|
|
AudioManagerOutputChangedEvent,
|
|
AudioManagerStateChangedEvent,
|
|
AudioSinksChangedEvent,
|
|
AudioSourcesChangedEvent,
|
|
CommandModeOptionsChangedEvent,
|
|
ConfigLoadedEvent,
|
|
SRSClientsChangedEvent,
|
|
} from "../events";
|
|
import { CommandModeOptions, OlympusConfig } from "../interfaces";
|
|
import { TextToSpeechSource } from "./texttospeechsource";
|
|
import { Coalition } from "../types/types";
|
|
|
|
export class AudioManager {
|
|
#audioContext: AudioContext;
|
|
#devices: MediaDeviceInfo[] = [];
|
|
#input: MediaDeviceInfo;
|
|
#output: MediaDeviceInfo;
|
|
|
|
/* 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[] = [];
|
|
|
|
/* 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;
|
|
#port: number;
|
|
#endpoint: string;
|
|
#socket: WebSocket | null = null;
|
|
#guid: string = makeID(22);
|
|
#SRSClientUnitIDs: number[] = [];
|
|
#syncInterval: number;
|
|
#speechRecognition: boolean = true;
|
|
#internalTextToSpeechSource: TextToSpeechSource;
|
|
#coalition: Coalition = "blue";
|
|
#commandMode: string = BLUE_COMMANDER;
|
|
|
|
constructor() {
|
|
ConfigLoadedEvent.on((config: OlympusConfig) => {
|
|
config.audio.WSPort ? this.setPort(config.audio.WSPort) : this.setEndpoint(config.audio.WSEndpoint);
|
|
});
|
|
|
|
CommandModeOptionsChangedEvent.on((options: CommandModeOptions) => {
|
|
if (options.commandMode === BLUE_COMMANDER) {
|
|
this.setCoalition("blue");
|
|
} else if (options.commandMode === RED_COMMANDER) {
|
|
this.setCoalition("red");
|
|
}
|
|
this.#commandMode = options.commandMode;
|
|
});
|
|
|
|
let PTTKeys = ["KeyZ", "KeyX", "KeyC", "KeyV", "KeyB", "KeyN", "KeyM", "Comma", "Dot"];
|
|
PTTKeys.forEach((key, idx) => {
|
|
getApp()
|
|
.getShortcutManager()
|
|
.addShortcut(`PTT${idx}Active`, {
|
|
label: `PTT ${idx} active`,
|
|
keyDownCallback: () => this.getSinks()[idx]?.setPtt(true),
|
|
keyUpCallback: () => this.getSinks()[idx]?.setPtt(false),
|
|
code: key,
|
|
shiftKey: true,
|
|
ctrlKey: false,
|
|
altKey: false,
|
|
});
|
|
});
|
|
}
|
|
|
|
start() {
|
|
this.#syncInterval = window.setInterval(() => {
|
|
this.#syncRadioSettings();
|
|
}, 1000);
|
|
|
|
this.#audioContext = new AudioContext({ sampleRate: 16000 });
|
|
|
|
//@ts-ignore
|
|
if (this.#output) this.#audioContext.setSinkId(this.#output.deviceId);
|
|
|
|
/* Connect the audio websocket */
|
|
let res = location.toString().match(/(?:http|https):\/\/(.+):/);
|
|
if (res === null) res = location.toString().match(/(?:http|https):\/\/(.+)/);
|
|
|
|
let wsAddress = res ? res[1] : location.toString();
|
|
if (wsAddress.at(wsAddress.length - 1) === "/") wsAddress = wsAddress.substring(0, wsAddress.length - 1);
|
|
if (this.#endpoint) this.#socket = new WebSocket(`wss://${wsAddress}/${this.#endpoint}`);
|
|
else if (this.#port) this.#socket = new WebSocket(`ws://${wsAddress}:${this.#port}`);
|
|
else console.error("The audio backend was enabled but no port/endpoint was provided in the configuration");
|
|
|
|
if (!this.#socket) return;
|
|
|
|
/* Log the opening of the connection */
|
|
this.#socket.addEventListener("open", (event) => {
|
|
console.log("Connection to audio websocket successfull");
|
|
});
|
|
|
|
/* Log any websocket errors */
|
|
this.#socket.addEventListener("error", (event) => {
|
|
console.log("An error occurred while connecting the WebSocket: " + event);
|
|
});
|
|
|
|
/* Handle the reception of a new message */
|
|
this.#socket.addEventListener("message", (event) => {
|
|
this.#sinks.forEach(async (sink) => {
|
|
if (sink instanceof RadioSink) {
|
|
/* Extract the audio data as array */
|
|
let packetUint8Array = new Uint8Array(await event.data.arrayBuffer());
|
|
|
|
if (packetUint8Array[0] === MessageType.audio) {
|
|
/* Extract the encoded audio data */
|
|
let audioPacket = new AudioPacket();
|
|
audioPacket.fromByteArray(packetUint8Array.slice(1));
|
|
|
|
/* 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 && sink.getTuned()) {
|
|
sink.setReceiving(true);
|
|
|
|
sink.setTransmittingUnit(getApp().getUnitsManager().getUnitByID(audioPacket.getUnitID()) ?? undefined);
|
|
|
|
/* Make a copy of the array buffer for the playback pipeline to use */
|
|
var dst = new ArrayBuffer(audioPacket.getAudioData().buffer.byteLength);
|
|
new Uint8Array(dst).set(new Uint8Array(audioPacket.getAudioData().buffer));
|
|
sink.recordArrayBuffer(audioPacket.getAudioData().buffer);
|
|
sink.playBuffer(dst);
|
|
}
|
|
});
|
|
} else {
|
|
this.#SRSClientUnitIDs = JSON.parse(new TextDecoder().decode(packetUint8Array.slice(1))).unitIDs;
|
|
SRSClientsChangedEvent.dispatch();
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
/* Add the microphone source and connect it directly to the radio */
|
|
const microphoneSource = new MicrophoneSource(this.#input);
|
|
microphoneSource.initialize().then(() => {
|
|
this.#sinks.forEach((sink) => {
|
|
if (sink instanceof RadioSink) microphoneSource.connect(sink);
|
|
});
|
|
this.#sources.push(microphoneSource);
|
|
AudioSourcesChangedEvent.dispatch(getApp().getAudioManager().getSources());
|
|
|
|
let sessionRadios = getApp().getSessionDataManager().getSessionData().radios;
|
|
if (sessionRadios) {
|
|
/* Load session radios */
|
|
sessionRadios.forEach((options) => {
|
|
let newRadio = this.addRadio();
|
|
newRadio?.setFrequency(options.frequency);
|
|
newRadio?.setModulation(options.modulation);
|
|
newRadio?.setPan(options.pan);
|
|
});
|
|
} else {
|
|
/* Add two default radios and connect to the microphone*/
|
|
let newRadio = this.addRadio();
|
|
this.#sources.find((source) => source instanceof MicrophoneSource)?.connect(newRadio);
|
|
this.#sources.find((source) => source instanceof TextToSpeechSource)?.connect(newRadio);
|
|
newRadio.setPan(-1);
|
|
|
|
newRadio = this.addRadio();
|
|
this.#sources.find((source) => source instanceof MicrophoneSource)?.connect(newRadio);
|
|
this.#sources.find((source) => source instanceof TextToSpeechSource)?.connect(newRadio);
|
|
newRadio.setPan(1);
|
|
}
|
|
|
|
let sessionFileSources = getApp().getSessionDataManager().getSessionData().fileSources;
|
|
if (sessionFileSources) {
|
|
/* Load file sources */
|
|
sessionFileSources.forEach((options) => {
|
|
this.addFileSource();
|
|
});
|
|
}
|
|
|
|
let sessionUnitSinks = getApp().getSessionDataManager().getSessionData().unitSinks;
|
|
if (sessionUnitSinks) {
|
|
/* Load unit sinks */
|
|
sessionUnitSinks.forEach((options) => {
|
|
let unit = getApp().getUnitsManager().getUnitByID(options.ID);
|
|
if (unit) {
|
|
this.addUnitSink(unit);
|
|
}
|
|
});
|
|
}
|
|
|
|
let sessionConnections = getApp().getSessionDataManager().getSessionData().connections;
|
|
if (sessionConnections) {
|
|
sessionConnections.forEach((connection) => {
|
|
if (connection[0] < this.#sources.length && connection[1] < this.#sinks.length) this.#sources[connection[0]]?.connect(this.#sinks[connection[1]]);
|
|
});
|
|
}
|
|
|
|
this.#running = true;
|
|
AudioManagerStateChangedEvent.dispatch(this.#running);
|
|
});
|
|
|
|
const textToSpeechSource = new TextToSpeechSource();
|
|
this.#sources.push(textToSpeechSource);
|
|
|
|
navigator.mediaDevices.enumerateDevices().then((devices) => {
|
|
this.#devices = devices;
|
|
AudioManagerDevicesChangedEvent.dispatch(devices);
|
|
});
|
|
|
|
this.#internalTextToSpeechSource = new TextToSpeechSource();
|
|
}
|
|
|
|
stop() {
|
|
/* Stop everything and send update event */
|
|
this.#running = false;
|
|
this.#sources.forEach((source) => source.disconnect());
|
|
this.#sinks.forEach((sink) => sink.disconnect());
|
|
this.#sources = [];
|
|
this.#sinks = [];
|
|
this.#socket?.close();
|
|
|
|
window.clearInterval(this.#syncInterval);
|
|
|
|
AudioSourcesChangedEvent.dispatch(this.#sources);
|
|
AudioSinksChangedEvent.dispatch(this.#sinks);
|
|
AudioManagerStateChangedEvent.dispatch(this.#running);
|
|
}
|
|
|
|
setPort(port) {
|
|
this.#port = port;
|
|
}
|
|
|
|
setEndpoint(endpoint) {
|
|
this.#endpoint = endpoint;
|
|
}
|
|
|
|
addFileSource() {
|
|
console.log(`Adding file source`);
|
|
const newSource = new FileSource();
|
|
this.#sources.push(newSource);
|
|
AudioSourcesChangedEvent.dispatch(getApp().getAudioManager().getSources());
|
|
return newSource;
|
|
}
|
|
|
|
getSources() {
|
|
return this.#sources;
|
|
}
|
|
|
|
removeSource(source: AudioSource) {
|
|
console.log(`Removing source ${source.getName()}`);
|
|
source.disconnect();
|
|
this.#sources = this.#sources.filter((v) => v != source);
|
|
AudioSourcesChangedEvent.dispatch(this.#sources);
|
|
}
|
|
|
|
addUnitSink(unit: Unit) {
|
|
console.log(`Adding unit sink for unit with ID ${unit.ID}`);
|
|
const newSink = new UnitSink(unit);
|
|
this.#sinks.push(newSink);
|
|
AudioSinksChangedEvent.dispatch(this.#sinks);
|
|
return newSink;
|
|
}
|
|
|
|
addRadio() {
|
|
console.log("Adding new radio");
|
|
const newRadio = new RadioSink();
|
|
this.#sinks.push(newRadio);
|
|
/* Set radio name by default to be incremental number */
|
|
newRadio.setName(`Radio ${this.#sinks.length}`);
|
|
AudioSinksChangedEvent.dispatch(this.#sinks);
|
|
return newRadio;
|
|
}
|
|
|
|
getSinks() {
|
|
return this.#sinks;
|
|
}
|
|
|
|
removeSink(sink) {
|
|
console.log(`Removing sink ${sink.getName()}`);
|
|
sink.disconnect();
|
|
this.#sinks = this.#sinks.filter((v) => v != sink);
|
|
let idx = 1;
|
|
/* If a radio was removed, rename all the remainin radios to align the names */
|
|
this.#sinks.forEach((sink) => {
|
|
if (sink instanceof RadioSink) sink.setName(`Radio ${idx++}`);
|
|
});
|
|
AudioSinksChangedEvent.dispatch(getApp().getAudioManager().getSinks());
|
|
|
|
/* Disconnect all the sources that where connected to this sink */
|
|
this.#sources.forEach((source) => {
|
|
if (source.getConnectedTo().includes(sink)) source.disconnect(sink);
|
|
});
|
|
}
|
|
|
|
getGuid() {
|
|
return this.#guid;
|
|
}
|
|
|
|
send(array) {
|
|
this.#socket?.send(array);
|
|
}
|
|
|
|
getAudioContext() {
|
|
return this.#audioContext;
|
|
}
|
|
|
|
getSRSClientsUnitIDs() {
|
|
return this.#SRSClientUnitIDs;
|
|
}
|
|
|
|
isRunning() {
|
|
return this.#running;
|
|
}
|
|
|
|
setInput(input: MediaDeviceInfo) {
|
|
if (this.#devices.includes(input)) {
|
|
this.#input = input;
|
|
AudioManagerInputChangedEvent.dispatch(input);
|
|
this.stop();
|
|
this.start();
|
|
} else {
|
|
console.error("Requested input device is not in devices list");
|
|
}
|
|
}
|
|
|
|
setOutput(output: MediaDeviceInfo) {
|
|
if (this.#devices.includes(output)) {
|
|
this.#input = output;
|
|
AudioManagerOutputChangedEvent.dispatch(output);
|
|
this.stop();
|
|
this.start();
|
|
} else {
|
|
console.error("Requested output device is not in devices list");
|
|
}
|
|
}
|
|
|
|
playText(text) {
|
|
this.#sources.find((source) => source instanceof TextToSpeechSource)?.playText(text);
|
|
}
|
|
|
|
setSpeechRecognition(speechRecognition: boolean) {
|
|
this.#speechRecognition = speechRecognition;
|
|
}
|
|
|
|
getSpeechRecognition() {
|
|
return this.#speechRecognition;
|
|
}
|
|
|
|
getInternalTextToSpeechSource() {
|
|
return this.#internalTextToSpeechSource;
|
|
}
|
|
|
|
setCoalition(coalition: Coalition) {
|
|
if (this.#commandMode === GAME_MASTER) {
|
|
this.#coalition = coalition;
|
|
AudioManagerCoalitionChangedEvent.dispatch(coalition);
|
|
}
|
|
}
|
|
|
|
getCoalition() {
|
|
return this.#coalition;
|
|
}
|
|
|
|
#syncRadioSettings() {
|
|
/* Send the radio settings of each radio to the SRS backend */
|
|
let message = {
|
|
type: "Settings update",
|
|
guid: this.#guid,
|
|
coalition: this.#coalition,
|
|
settings: this.#sinks
|
|
.filter((sink) => sink instanceof RadioSink)
|
|
.map((radio) => {
|
|
return {
|
|
frequency: radio.getFrequency(),
|
|
modulation: radio.getModulation(),
|
|
ptt: radio.getPtt(),
|
|
};
|
|
}),
|
|
};
|
|
|
|
if (this.#socket?.readyState == 1) this.#socket?.send(new Uint8Array([AudioMessageType.settings, ...Buffer.from(JSON.stringify(message), "utf-8")]));
|
|
}
|
|
}
|