Implemented session data for audio

This commit is contained in:
Pax1601 2024-12-03 18:54:30 +01:00
parent 5e40d7abf1
commit f17ee42d63
18 changed files with 212 additions and 208 deletions

View File

@ -52,6 +52,7 @@ export class AudioManager {
#SRSClientUnitIDs: number[] = [];
#syncInterval: number;
#speechRecognition: boolean = true;
#internalTextToSpeechSource: TextToSpeechSource;
constructor() {
ConfigLoadedEvent.on((config: OlympusConfig) => {
@ -161,23 +162,41 @@ export class AudioManager {
newRadio?.setModulation(options.modulation);
});
} else {
/* Add two default radios */
this.addRadio();
this.addRadio();
/* 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 = this.addRadio();
this.#sources.find((source) => source instanceof MicrophoneSource)?.connect(newRadio);
this.#sources.find((source) => source instanceof TextToSpeechSource)?.connect(newRadio);
}
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 session radios */
/* Load unit sinks */
sessionUnitSinks.forEach((options) => {
let unit = getApp().getUnitsManager().getUnitByID(options.ID);
if (unit) {
let newSink = this.addUnitSink(unit);
this.addUnitSink(unit);
}
});
}
let sessionFileSources = getApp().getSessionDataManager().getSessionData().fileSources;
if (sessionFileSources && sessionFileSources.length > 0) getApp().setState(OlympusState.LOAD_FILES);
let sessionConnections = getApp().getSessionDataManager().getSessionData().connections;
if (sessionConnections) {
sessionConnections.forEach((connection) => {
this.#sources[connection[0]]?.connect(this.#sinks[connection[1]]);
})
}
this.#running = true;
AudioManagerStateChangedEvent.dispatch(this.#running);
@ -190,6 +209,8 @@ export class AudioManager {
this.#devices = devices;
AudioManagerDevicesChangedEvent.dispatch(devices);
});
this.#internalTextToSpeechSource = new TextToSpeechSource();
}
stop() {
@ -220,9 +241,9 @@ export class AudioManager {
this.#endpoint = endpoint;
}
addFileSource(file) {
console.log(`Adding file source from ${file.name}`);
const newSource = new FileSource(file);
addFileSource() {
console.log(`Adding file source`);
const newSource = new FileSource();
this.#sources.push(newSource);
AudioSourcesChangedEvent.dispatch(getApp().getAudioManager().getSources());
return newSource;
@ -250,11 +271,10 @@ export class AudioManager {
addRadio() {
console.log("Adding new radio");
const newRadio = new RadioSink();
newRadio.speechDataAvailable = (blob) => this.#speechController.analyzeData(blob);
newRadio.speechDataAvailable = (blob) => this.#speechController.analyzeData(blob, newRadio);
this.#sinks.push(newRadio);
/* Set radio name by default to be incremental number */
newRadio.setName(`Radio ${this.#sinks.length}`);
this.#sources.find((source) => source instanceof MicrophoneSource)?.connect(newRadio);
AudioSinksChangedEvent.dispatch(this.#sinks);
return newRadio;
}
@ -327,13 +347,17 @@ export class AudioManager {
}
setSpeechRecognition(speechRecognition: boolean) {
this.#speechRecognition = this.#speechRecognition;
this.#speechRecognition = speechRecognition;
}
getSpeechRecognition() {
return this.#speechRecognition;
}
getInternalTextToSpeechSource() {
return this.#internalTextToSpeechSource;
}
#syncRadioSettings() {
/* Send the radio settings of each radio to the SRS backend */
let message = {

View File

@ -15,13 +15,12 @@ export class FileSource extends AudioSource {
#restartTimeout: any;
#looping = false;
constructor(file) {
super();
setFile(file: File) {
this.#file = file;
this.#filename = this.#file?.name;
this.setName(this.#file?.name ?? "N/A");
/* Create the file reader and read the file from disk */
var reader = new FileReader();
reader.onload = (e) => {
@ -38,6 +37,8 @@ export class FileSource extends AudioSource {
}
};
reader.readAsArrayBuffer(this.#file);
AudioSourcesChangedEvent.dispatch(getApp().getAudioManager().getSources());
}
play() {

View File

@ -1,10 +1,12 @@
import { getApp } from "../olympusapp";
import { blobToBase64 } from "../other/utils";
import { AudioSource } from "./audiosource";
import { RadioSink } from "./radiosink";
export class SpeechController {
constructor() {}
analyzeData(blob: Blob) {
analyzeData(blob: Blob, radio: RadioSink) {
blobToBase64(blob)
.then((base64) => {
const requestOptions = {
@ -23,23 +25,45 @@ export class SpeechController {
throw new Error("Error saving profile");
}
})
.then((text) => this.#executeCommand(text))
.then((text) => this.#executeCommand(text.toLowerCase(), radio))
.catch((error) => console.error(error)); // Handle errors
})
.catch((error) => console.error(error));
}
#executeCommand(text) {
playText(text, radio: RadioSink) {
const textToSpeechSource = getApp()
.getAudioManager()
.getInternalTextToSpeechSource();
textToSpeechSource.connect(radio);
textToSpeechSource.playText(text);
radio.setPtt(true);
textToSpeechSource.onMessageCompleted = () => {
radio.setPtt(false);
textToSpeechSource.disconnect(radio);
}
}
#executeCommand(text, radio) {
console.log(`Received speech command: ${text}`);
if (text.indexOf("olympus") === 0 ) {
this.#olympusCommand(text);
this.#olympusCommand(text, radio);
} else if (text.indexOf(getApp().getAWACSController()?.getCallsign().toLowerCase()) === 0) {
getApp().getAWACSController()?.executeCommand(text);
getApp().getAWACSController()?.executeCommand(text, radio);
}
}
#olympusCommand(text) {
#olympusCommand(text, radio) {
if (text.indexOf("request straight") > 0 || text.indexOf("request straightin") > 0) {
this.playText("Confirm you are on step 13, being a pussy?", radio);
}
else if (text.indexOf("bolter") > 0) {
this.playText("What an idiot, I never boltered, 100% boarding rate", radio);
}
else if (text.indexOf("read back") > 0) {
this.playText(text.replace("olympus", ""), radio);
}
}
}

View File

@ -12,11 +12,12 @@ export class TextToSpeechSource extends AudioSource {
#audioBuffer: AudioBuffer;
#restartTimeout: any;
#looping = false;
onMessageCompleted: () => void = () => {};
constructor() {
super();
this.setName("Text to speech")
this.setName("Text to speech");
}
playText(text: string) {
@ -36,7 +37,7 @@ export class TextToSpeechSource extends AudioSource {
}
}) // Parse the response
.then((blob) => {
return blob.arrayBuffer()
return blob.arrayBuffer();
})
.then((contents) => {
getApp()
@ -76,7 +77,10 @@ export class TextToSpeechSource extends AudioSource {
if (this.#currentPosition > this.#duration) {
this.#currentPosition = 0;
if (!this.#looping) this.pause();
if (!this.#looping) {
this.pause();
this.onMessageCompleted();
}
}
AudioSourcesChangedEvent.dispatch(getApp().getAudioManager().getSources());
@ -133,4 +137,3 @@ export class TextToSpeechSource extends AudioSource {
return this.#looping;
}
}

View File

@ -286,8 +286,7 @@ export enum OlympusState {
OPTIONS = "Options",
AUDIO = "Audio",
AIRBASE = "Airbase",
GAME_MASTER = "Game master",
LOAD_FILES = "Load files"
GAME_MASTER = "Game master"
}
export const NO_SUBSTATE = "No substate";

View File

@ -2,6 +2,7 @@ import { getApp } from "../olympusapp";
import { Coalition } from "../types/types";
import { Unit } from "../unit/unit";
import { bearing, coalitionToEnum, computeBearingRangeString, mToFt, rad2deg } from "../other/utils";
import { TextToSpeechSource } from "../audio/texttospeechsource";
const trackStrings = ["North", "North-East", "East", "South-East", "South", "South-West", "West", "North-West", "North"];
const relTrackStrings = ["hot", "flank right", "beam right", "cold", "cold", "cold", "beam left", "flank left", "hot"];
@ -11,9 +12,11 @@ export class AWACSController {
#callsign: string = "Magic";
#referenceUnit: Unit;
constructor() {}
constructor() {
executeCommand(text) {
}
executeCommand(text, radio) {
if (text.indexOf("request picture") > 0) {
console.log("Requested AWACS picture");
const readout = this.createPicture(true);

View File

@ -33,6 +33,7 @@ export interface SessionData {
radios?: { frequency: number; modulation: number }[];
fileSources?: { filename: string; volume: number }[];
unitSinks?: {ID: number}[];
connections?: any[];
}
export interface ProfileOptions {

View File

@ -1,3 +1,4 @@
import { AudioSink } from "./audio/audiosink";
import { FileSource } from "./audio/filesource";
import { RadioSink } from "./audio/radiosink";
import { UnitSink } from "./audio/unitsink";
@ -26,10 +27,23 @@ export class SessionDataManager {
.filter((sink) => sink instanceof UnitSink)
.map((unitSink) => {
return {
ID: unitSink.getUnit().ID
ID: unitSink.getUnit().ID,
};
});
this.#sessionData.connections = [];
let counter = 0;
let sources = getApp().getAudioManager().getSources();
let sinks = getApp().getAudioManager().getSinks();
sources.forEach((source, idx) => {
counter++;
source.getConnectedTo().forEach((sink) => {
if (sinks.indexOf(sink as AudioSink) !== undefined) {
this.#sessionData.connections?.push([idx, sinks.indexOf(sink as AudioSink)]);
}
});
});
this.#saveSessionData();
}
});
@ -41,6 +55,20 @@ export class SessionDataManager {
.map((fileSource) => {
return { filename: fileSource.getFilename(), volume: fileSource.getVolume() };
});
this.#sessionData.connections = [];
let counter = 0;
let sources = getApp().getAudioManager().getSources();
let sinks = getApp().getAudioManager().getSinks();
sources.forEach((source, idx) => {
counter++;
source.getConnectedTo().forEach((sink) => {
if (sinks.indexOf(sink as AudioSink) !== undefined) {
this.#sessionData.connections?.push([idx, sinks.indexOf(sink as AudioSink)]);
}
});
});
this.#saveSessionData();
}
});

View File

@ -10,10 +10,10 @@ export function OlFrequencyInput(props: { value: number; className?: string; onC
flex gap-2
`}>
<OlNumberInput
min={1}
min={0}
max={400}
onChange={(e) => {
let newValue = Math.max(Math.min(Number(e.target.value), 400), 1) * 1000000;
let newValue = Math.max(Math.min(Number(e.target.value), 400), 0) * 1000000;
let decimalPart = frequency - Math.floor(frequency / 1000000) * 1000000;
frequency = newValue + decimalPart;
props.onChange(frequency);

View File

@ -3,7 +3,10 @@ import React from "react";
export function OlLabelToggle(props: { toggled: boolean | undefined; leftLabel: string; rightLabel: string; onClick: () => void }) {
return (
<button
onClick={props.onClick}
onClick={(e) => {
e.stopPropagation();
props.onClick();
}}
className={`
relative flex h-10 w-[120px] flex-none cursor-pointer select-none
flex-row content-center justify-between rounded-md border px-1 py-[5px]

View File

@ -21,7 +21,10 @@ export function OlNumberInput(props: {
<div className="relative flex max-w-[8rem] items-center">
<button
type="button"
onClick={props.onDecrease}
onClick={(e) => {
e.stopPropagation();
props.onDecrease();
}}
className={`
h-10 rounded-s-lg bg-gray-100 p-3
dark:bg-gray-700 dark:hover:bg-gray-600 dark:focus:ring-blue-700
@ -45,6 +48,7 @@ export function OlNumberInput(props: {
<input
type="text"
onChange={props.onChange}
onClick={(e) => e.stopPropagation()}
min={props.min}
max={props.max}
className={`
@ -59,7 +63,10 @@ export function OlNumberInput(props: {
/>
<button
type="button"
onClick={props.onIncrease}
onClick={(e) => {
e.stopPropagation();
props.onIncrease();
}}
className={`
h-10 rounded-e-lg bg-gray-100 p-3
dark:bg-gray-700 dark:hover:bg-gray-600 dark:focus:ring-blue-500

View File

@ -30,13 +30,14 @@ export function OlStateButton(props: {
textColor = "#243141";
}
const opacity = (hover && !props.checked) ? "AA" : "FF";
const opacity = hover && !props.checked ? "AA" : "FF";
return (
<>
<button
ref={buttonRef}
onClick={() => {
onClick={(e) => {
e.stopPropagation();
props.onClick();
props.onClick ?? setHover(false);
}}

View File

@ -2,10 +2,16 @@ import React from "react";
export function OlToggle(props: { toggled: boolean | undefined; onClick: () => void }) {
return (
<div className="inline-flex cursor-pointer items-center" onClick={props.onClick}>
<div
className="inline-flex cursor-pointer items-center"
onClick={(e) => {
e.stopPropagation();
props.onClick();
}}
>
<button className="peer sr-only" />
<div
data-toggled={props.toggled === true? 'true': props.toggled === undefined? 'undefined': 'false'}
data-toggled={props.toggled === true ? "true" : props.toggled === undefined ? "undefined" : "false"}
className={`
peer relative h-7 w-14 rounded-full bg-gray-200
after:absolute after:start-[4px] after:top-0.5 after:h-6 after:w-6

View File

@ -1,129 +0,0 @@
import React, { useEffect, useState } from "react";
import { Modal } from "./components/modal";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowRight, faCheck, faUpload } from "@fortawesome/free-solid-svg-icons";
import { getApp } from "../../olympusapp";
import { OlympusState } from "../../constants/constants";
import { SessionDataLoadedEvent } from "../../events";
export function FileSourceLoadPrompt(props: { open: boolean }) {
const [files, setFiles] = useState([] as { filename: string; volume: number }[]);
const [loaded, setLoaded] = useState([] as boolean[]);
useEffect(() => {
SessionDataLoadedEvent.on((sessionData) => {
if (getApp().getState() === OlympusState.LOAD_FILES) return; // TODO don't like this, is hacky Should avoid reading state directly
if (sessionData.fileSources) {
setFiles([...sessionData.fileSources]);
setLoaded(
sessionData.fileSources.map((file) => {
return false;
})
);
}
});
}, []);
return (
<Modal
open={props.open}
className={`
inline-flex h-fit max-h-[800px] w-[600px] overflow-y-auto scroll-smooth
bg-white p-10
dark:bg-olympus-800
max-md:h-full max-md:max-h-full max-md:w-full max-md:rounded-none
max-md:border-none
`}
>
<div className="flex h-full w-full flex-col gap-12">
<div className={`flex flex-col items-start gap-2`}>
<span
className={`
text-gray-800 text-md
dark:text-white
`}
>
Please, select the files for the following audio sources
</span>
<span
className={`
text-gray-800 text-md
dark:text-gray-500
`}
>
Browsers can't automatically load files from your computer, therefore you must click on the following buttons to select the original files for each
audio file source.
</span>
<span
className={`
text-gray-800 text-md
dark:text-gray-500
`}
>
If you don't want to reload your audio sources, press "Skip".
</span>
<div className="mt-4 w-full">
{files.map((fileData, idx) => {
return (
<div
className={`flex w-full content-center justify-between gap-4`}
>
<span className={`my-auto truncate text-white`}>{fileData.filename}</span>
<button
type="button"
disabled={loaded[idx] === true || (idx > 0 && loaded[idx - 1] == false)}
data-disabled={loaded[idx] === true || (idx > 0 && loaded[idx - 1] == false)}
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).setVolume(fileData.volume);
loaded[idx] = true;
setLoaded([...loaded]);
if (idx === loaded.length - 1) getApp().setState(OlympusState.IDLE);
}
};
}}
className={`
mb-2 me-2 ml-auto flex cursor-pointer content-center
items-center gap-2 rounded-sm bg-blue-600 px-5 py-2.5
text-sm font-medium text-white
data-[disabled="true"]:bg-blue-800
focus:outline-none focus:ring-4 focus:ring-blue-800
hover:bg-blue-700
`}
>
{loaded[idx] ? "Loaded" : "Load"}
<FontAwesomeIcon className={`my-auto`} icon={loaded[idx] ? faCheck : faUpload} />
</button>
</div>
);
})}
</div>
</div>
<div className="flex">
<button
type="button"
onClick={() => {getApp().setState(OlympusState.IDLE)}}
className={`
mb-2 me-2 ml-auto flex content-center items-center gap-2
rounded-sm border-[1px] bg-blue-700 px-5 py-2.5 text-sm
font-medium text-white
dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400
dark:hover:bg-gray-700 dark:focus:ring-blue-800
focus:outline-none focus:ring-4 focus:ring-blue-300
hover:bg-blue-800
`}
>
Skip
<FontAwesomeIcon className={`my-auto`} icon={faArrowRight} />
</button>
</div>
</div>
</Modal>
);
}

View File

@ -202,18 +202,7 @@ export function AudioMenu(props: { open: boolean; onClose: () => void; children?
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);
}
};
}}
onClick={() => getApp().getAudioManager().addFileSource()}
>
Add audio source
</button>

View File

@ -18,11 +18,14 @@ export const RadioSinkPanel = forwardRef((props: { radio: RadioSink; shortcutKey
<div
data-receiving={props.radio.getReceiving()}
className={`
box-border flex flex-col content-center justify-between gap-2 rounded-md
border-2 border-transparent bg-olympus-200/30 px-4 py-3
box-border flex cursor-pointer flex-col content-center justify-between
gap-2 rounded-md border-2 border-transparent bg-olympus-200/30 px-4 py-3
data-[receiving='true']:border-white
`}
ref={ref}
onClick={() => {
setExpanded(!expanded);
}}
>
<div className="flex content-center justify-between gap-2">
<div

View File

@ -8,6 +8,7 @@ import { OlRangeSlider } from "../../components/olrangeslider";
import { FileSource } from "../../../audio/filesource";
import { MicrophoneSource } from "../../../audio/microphonesource";
import { TextToSpeechSource } from "../../../audio/texttospeechsource";
import { FaUpload } from "react-icons/fa";
export const AudioSourcePanel = forwardRef((props: { source: AudioSource; onExpanded: () => void }, ref: ForwardedRef<HTMLDivElement>) => {
const [meterLevel, setMeterLevel] = useState(0);
@ -48,7 +49,47 @@ export const AudioSourcePanel = forwardRef((props: { source: AudioSource; onExpa
/>
</div>
<div className="flex w-full overflow-hidden">
<span className={`my-auto truncate`}>{props.source.getName()}</span>
<div className={`my-auto w-full truncate`}>
{props.source.getName() === "" ? (
props.source instanceof FileSource ? (
<div
className="flex w-full content-center justify-between"
>
<span className={`my-auto text-red-500`}>No file selected</span>
<button
type="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];
(props.source as FileSource).setFile(file)
}
};
}}
className={`
flex cursor-pointer content-center items-center gap-2
rounded-sm bg-blue-600 px-5 py-2.5 text-sm font-medium
text-white
data-[disabled="true"]:bg-blue-800
focus:outline-none focus:ring-4 focus:ring-blue-800
hover:bg-blue-700
`}
>
<FaUpload className={`my-auto`} />
</button>
</div>
) : (
"No name"
)
) : (
props.source.getName()
)}
</div>
</div>
{!(props.source instanceof MicrophoneSource) && !(props.source instanceof TextToSpeechSource) && (
<div
@ -68,22 +109,22 @@ export const AudioSourcePanel = forwardRef((props: { source: AudioSource; onExpa
<>
{(props.source instanceof FileSource || props.source instanceof TextToSpeechSource) && (
<div className="flex flex-col gap-2 rounded-md bg-olympus-400 p-2">
{props.source instanceof TextToSpeechSource &&
<input
className={`
block h-10 w-full border-[2px] bg-gray-50 py-2.5 text-center
text-sm text-gray-900
dark:border-gray-700 dark:bg-olympus-600 dark:text-white
dark:placeholder-gray-400 dark:focus:border-blue-700
dark:focus:ring-blue-700
focus:border-blue-700 focus:ring-blue-500
`}
value={text}
onChange={(ev) => {
setText(ev.target.value);
}}
></input>
}
{props.source instanceof TextToSpeechSource && (
<input
className={`
block h-10 w-full border-[2px] bg-gray-50 py-2.5 text-center
text-sm text-gray-900
dark:border-gray-700 dark:bg-olympus-600 dark:text-white
dark:placeholder-gray-400 dark:focus:border-blue-700
dark:focus:ring-blue-700
focus:border-blue-700 focus:ring-blue-500
`}
value={text}
onChange={(ev) => {
setText(ev.target.value);
}}
></input>
)}
<div className="flex gap-4">
<OlStateButton
checked={false}
@ -97,7 +138,8 @@ export const AudioSourcePanel = forwardRef((props: { source: AudioSource; onExpa
<OlRangeSlider
value={props.source.getDuration() > 0 ? (props.source.getCurrentPosition() / props.source.getDuration()) * 100 : 0}
onChange={(ev) => {
if (props.source instanceof FileSource || props.source instanceof TextToSpeechSource) props.source.setCurrentPosition(parseFloat(ev.currentTarget.value));
if (props.source instanceof FileSource || props.source instanceof TextToSpeechSource)
props.source.setCurrentPosition(parseFloat(ev.currentTarget.value));
}}
className="my-auto"
/>
@ -124,9 +166,10 @@ export const AudioSourcePanel = forwardRef((props: { source: AudioSource; onExpa
flex-row border-gray-500
`}
>
<div style={{ minWidth: `${meterLevel * 100}%` }} className={`
rounded-full bg-gray-200
`}></div>
<div
style={{ minWidth: `${meterLevel * 100}%` }}
className={`rounded-full bg-gray-200`}
></div>
</div>
<OlRangeSlider
value={props.source.getVolume() * 100}

View File

@ -11,7 +11,6 @@ import { MapHiddenTypes, MapOptions } from "../types/types";
import { NO_SUBSTATE, OlympusState, OlympusSubState, OptionsSubstate, SpawnSubState, UnitControlSubState } from "../constants/constants";
import { getApp, setupApp } from "../olympusapp";
import { LoginModal } from "./modals/loginmodal";
import { FileSourceLoadPrompt } from "./modals/filesourceloadprompt";
import { MiniMapPanel } from "./panels/minimappanel";
import { UnitControlBar } from "./panels/unitcontrolbar";
@ -70,8 +69,7 @@ export function UI() {
<LoginModal open={appState === OlympusState.LOGIN} />
<ProtectionPromptModal open={appState === OlympusState.UNIT_CONTROL && appSubState == UnitControlSubState.PROTECTION} />
<KeybindModal open={appState === OlympusState.OPTIONS && appSubState === OptionsSubstate.KEYBIND} />
<FileSourceLoadPrompt open={appState === OlympusState.LOAD_FILES}/>
<div id="map-container" className="z-0 h-full w-screen" />
<MainMenu open={appState === OlympusState.MAIN_MENU} onClose={() => getApp().setState(OlympusState.IDLE)} />