DCSOlympus/frontend/react/src/audio/audiomanager.ts
2025-02-26 10:39:08 +01:00

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")]));
}
}