More work on external audio sources, started adding generic audio packet handling

This commit is contained in:
Davide Passoni
2024-09-06 18:12:09 +02:00
parent 9c2ce526d3
commit 5726d6dee2
18 changed files with 472 additions and 175 deletions

View File

@@ -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;

View File

@@ -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];

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

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

View File

@@ -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());
}

View File

@@ -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 */

View File

@@ -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[] }) {

View File

@@ -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 (
<div
className={`
flex flex-col content-center justify-between gap-2 rounded-md
bg-olympus-200/30 py-3 pl-4 pr-5
`}
>
<span>{props.source.getName()}</span>
{props.source instanceof FileSource && (
<div className="flex flex-col gap-2">
<div className="flex gap-4">
<OlStateButton
checked={false}
icon={props.source.getPlaying() ? faPause : faPlay}
onClick={() => {
if (props.source instanceof FileSource) props.source.getPlaying() ? props.source.stop() : props.source.play();
}}
tooltip="Play file"
></OlStateButton>
<OlRangeSlider
value={(props.source.getCurrentPosition() / props.source.getDuration()) * 100}
onChange={(ev) => {
if (props.source instanceof FileSource) props.source.setCurrentPosition(parseFloat(ev.currentTarget.value));
}}
className="my-auto"
/>
<OlStateButton
checked={props.source.getLooping()}
icon={faRepeat}
onClick={() => {
if (props.source instanceof FileSource) props.source.setLooping(!props.source.getLooping());
}}
tooltip="Loop"
></OlStateButton>
</div>
<div className="flex gap-4">
<div className="h-[40px] min-w-[40px] p-2">
<FaVolumeHigh className="h-full w-full" />
</div>
<div className="relative flex w-full flex-col gap-3">
<div
className={`
absolute top-[18px] flex h-2 min-w-full translate-y-[-5px]
flex-row border-gray-500
`}
>
<div
style={{ minWidth: `${meterLevel * 100}%` }}
className={`rounded-full bg-gray-200`}
></div>
</div>
<OlRangeSlider
value={props.source.getVolume() * 100}
onChange={(ev) => {
if (props.source instanceof FileSource) props.source.setVolume(parseFloat(ev.currentTarget.value) / 100);
}}
className="absolute top-[18px]"
/>
</div>
<div className="h-[40px] min-w-[40px] p-2">
<span>{Math.round(props.source.getVolume() * 100)}</span>
</div>
</div>
</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 className="cursor-pointer" onClick={() => props.source.disconnect(sink)}></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

@@ -17,7 +17,7 @@ export function RadioPanel(props: { radio: RadioSink }) {
>
<div className="flex content-center justify-between">
<span className="my-auto">{props.radio.getName()}</span>
<div className="rounded-md bg-red-800 p-2" onClick={() => {getApp().getAudioManager().removeSink(props.radio);}}>
<div className="cursor-pointer rounded-md bg-red-800 p-2" onClick={() => {getApp().getAudioManager().removeSink(props.radio);}}>
<FaTrash className={`text-gray-50`}></FaTrash>
</div>
</div>

View File

@@ -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 (
<div
className={`
flex flex-col content-center justify-between gap-2 rounded-md
bg-olympus-200/30 py-3 pl-4 pr-5
`}
>
<div className="flex justify-between gap-2">
<span className="break-all">{props.source.getName()}</span>
{!(props.source instanceof MicrophoneSource) && (
<div
className={`
mb-auto aspect-square cursor-pointer rounded-md bg-red-800 p-2
`}
onClick={() => {
getApp().getAudioManager().removeSource(props.source);
}}
>
<FaTrash className={`text-gray-50`}></FaTrash>
</div>
)}
</div>
<div className="flex flex-col gap-2 rounded-md bg-olympus-400 p-2">
{props.source instanceof FileSource && (
<div className="flex gap-4">
<OlStateButton
checked={false}
icon={props.source.getPlaying() ? faPause : faPlay}
onClick={() => {
if (props.source instanceof FileSource) props.source.getPlaying() ? props.source.stop() : props.source.play();
}}
tooltip="Play file"
></OlStateButton>
<OlRangeSlider
value={props.source.getDuration() > 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"
/>
<OlStateButton
checked={props.source.getLooping()}
icon={faRepeat}
onClick={() => {
if (props.source instanceof FileSource) props.source.setLooping(!props.source.getLooping());
}}
tooltip="Loop"
></OlStateButton>
</div>
)}
<div className="flex gap-4">
<div className="h-[40px] min-w-[40px] p-2">
<FaVolumeHigh className="h-full w-full" />
</div>
<div className="relative flex w-full flex-col gap-3">
<div
className={`
absolute top-[18px] flex h-2 min-w-full translate-y-[-5px]
flex-row border-gray-500
`}
>
<div style={{ minWidth: `${meterLevel * 100}%` }} className={`
rounded-full bg-gray-200
`}></div>
</div>
<OlRangeSlider
value={props.source.getVolume() * 100}
onChange={(ev) => {
props.source.setVolume(parseFloat(ev.currentTarget.value) / 100);
}}
className="absolute top-[18px]"
/>
</div>
<div className="h-[40px] min-w-[40px] p-2">
<span>{Math.round(props.source.getVolume() * 100)}</span>
</div>
</div>
</div>
<span className="text-sm">Connected to:</span>
<div className="flex flex-col gap-1">
{props.source.getConnectedTo().map((sink) => {
return (
<div
className={`
flex justify-start gap-2 rounded-full bg-olympus-400 px-4 py-1
text-sm
`}
>
<FaArrowRight className="my-auto"></FaArrowRight>
{sink.getName()}
<FaUnlink className="my-auto ml-auto cursor-pointer text-red-400" onClick={() => props.source.disconnect(sink)}></FaUnlink>
</div>
);
})}
</div>
{availabileSinks.length > 0 && (
<OlDropdown label="Connect to:">
{availabileSinks.map((sink) => {
return (
<OlDropdownItem
onClick={() => {
props.source.connect(sink);
}}
>
{sink.getName()}
</OlDropdownItem>
);
})}
</OlDropdown>
)}
</div>
);
}

View File

@@ -134,14 +134,6 @@ export function UI() {
setLoginModalVisible(false);
}
/* Temporary during devel */
//useEffect(() => {
// window.setTimeout(() => {
// checkPassword("admin");
// connect("devel");
// }, 1000)
//}, [])
return (
<div
className={`

View File

@@ -850,8 +850,7 @@ export abstract class Unit extends CustomMarker {
if (targetPosition) getApp().getUnitsManager().addDestination(targetPosition, false, 0, units);
}
);
/* Temporarily removed until more work can be done for external sounds
contextActionSet.addContextAction(
this,
"speaker",
@@ -864,7 +863,6 @@ export abstract class Unit extends CustomMarker {
},
{ executeImmediately: true }
);
*/
contextActionSet.addDefaultContextAction(
this,