diff --git a/frontend/react/package.json b/frontend/react/package.json index 0f73e905..7b512e08 100644 --- a/frontend/react/package.json +++ b/frontend/react/package.json @@ -53,6 +53,7 @@ "prettier": "^3.3.2", "tailwindcss": "^3.4.3", "typescript-eslint": "^7.14.1", - "vite": "^5.2.0" + "vite": "^5.2.0", + "web-audio-peak-meter": "^3.1.0" } } diff --git a/frontend/react/src/audio/audiosource.ts b/frontend/react/src/audio/audiosource.ts index a257389e..2fbdf47f 100644 --- a/frontend/react/src/audio/audiosource.ts +++ b/frontend/react/src/audio/audiosource.ts @@ -11,8 +11,14 @@ export abstract class AudioSource { document.dispatchEvent(new CustomEvent("audioSourcesUpdated")); } - disconnect() { - this.getNode().disconnect(); + disconnect(sinkToDisconnect?: AudioSink) { + if (sinkToDisconnect !== undefined) { + this.getNode().disconnect(sinkToDisconnect.getNode()); + this.#connectedTo = this.#connectedTo.filter((sink) => sink != sinkToDisconnect); + } else { + this.getNode().disconnect(); + } + document.dispatchEvent(new CustomEvent("audioSourcesUpdated")); } diff --git a/frontend/react/src/audio/filesource.ts b/frontend/react/src/audio/filesource.ts index 38178798..f844144a 100644 --- a/frontend/react/src/audio/filesource.ts +++ b/frontend/react/src/audio/filesource.ts @@ -1,11 +1,21 @@ import { AudioSource } from "./audiosource"; -import { bufferToF32Planar } from "../other/utils"; 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; + #currentPosition: number = 0; + #updateInterval: any; + #lastUpdateTime: number = 0; + #playing = false; + #audioBuffer: AudioBuffer; + #restartTimeout: any; + #looping = false; + #meter: WebAudioPeakMeter; + #volume: number = 1.0; constructor(file) { super(); @@ -14,13 +24,7 @@ export class FileSource extends AudioSource { this.setName(this.#file?.name ?? "N/A"); this.#gainNode = getApp().getAudioManager().getAudioContext().createGain(); - } - getNode() { - return this.#gainNode; - } - - play() { if (!this.#file) { return; } @@ -28,23 +32,108 @@ export class FileSource extends AudioSource { reader.onload = (e) => { var contents = e.target?.result; if (contents) { - getApp().getAudioManager().getAudioContext().decodeAudioData(contents as ArrayBuffer, (arrayBuffer) => { - this.#source = getApp().getAudioManager().getAudioContext().createBufferSource(); - this.#source.buffer = arrayBuffer; - this.#source.connect(this.#gainNode); - this.#source.loop = true; - this.#source.start(); - }); + getApp() + .getAudioManager() + .getAudioContext() + .decodeAudioData(contents as ArrayBuffer, (audioBuffer) => { + this.#audioBuffer = audioBuffer; + this.#duration = audioBuffer.duration; + }); } }; 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.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; + this.#lastUpdateTime = now; + + document.dispatchEvent(new CustomEvent("audioSourcesUpdated")); + + this.#updateInterval = setInterval(() => { + const now = Date.now() / 1000; + this.#currentPosition += now - this.#lastUpdateTime; + this.#lastUpdateTime = now; + + if (this.#currentPosition > this.#duration) { + this.#currentPosition = 0; + if (!this.#looping) this.stop(); + } + + document.dispatchEvent(new CustomEvent("audioSourcesUpdated")); + }, 1000); + } + stop() { this.#source.stop(); + this.#playing = false; + + const now = Date.now() / 1000; + this.#currentPosition += now - this.#lastUpdateTime; + clearInterval(this.#updateInterval); + + document.dispatchEvent(new CustomEvent("audioSourcesUpdated")); } setGain(gain) { this.#gainNode.gain.setValueAtTime(gain, getApp().getAudioManager().getAudioContext().currentTime); } + + getPlaying() { + return this.#playing; + } + + getCurrentPosition() { + return this.#currentPosition; + } + + getDuration() { + return this.#duration; + } + + setCurrentPosition(percentPosition) { + if (this.#playing) { + clearTimeout(this.#restartTimeout); + this.#restartTimeout = setTimeout(() => this.play(), 1000); + } + + this.stop(); + this.#currentPosition = (percentPosition / 100) * this.#duration; + } + + setLooping(looping) { + this.#looping = looping; + if (this.#source) this.#source.loop = looping; + document.dispatchEvent(new CustomEvent("audioSourcesUpdated")); + } + + 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/ui/modals/components/card.tsx b/frontend/react/src/ui/modals/components/card.tsx index 3e4f7623..e2d2ab57 100644 --- a/frontend/react/src/ui/modals/components/card.tsx +++ b/frontend/react/src/ui/modals/components/card.tsx @@ -1,6 +1,6 @@ import React from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faArrowRight, faCheckCircle, faExternalLink, faLink, faUnlink } from "@fortawesome/free-solid-svg-icons"; +import { faArrowRight } from "@fortawesome/free-solid-svg-icons"; export function Card(props: { children?: JSX.Element | JSX.Element[]; className?: string }) { return ( diff --git a/frontend/react/src/ui/panels/components/audiosourcepanel.tsx b/frontend/react/src/ui/panels/components/audiosourcepanel.tsx index 8e45c77b..13bab676 100644 --- a/frontend/react/src/ui/panels/components/audiosourcepanel.tsx +++ b/frontend/react/src/ui/panels/components/audiosourcepanel.tsx @@ -1,14 +1,26 @@ import React, { useEffect, useState } from "react"; import { OlStateButton } from "../../components/olstatebutton"; -import { faPlay, faRepeat } from "@fortawesome/free-solid-svg-icons"; +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.getName() != "Microphone" && ( -
- { - props.source.play(); - }} - tooltip="Play file" - > - { - //let setting = props.setting; - //setting.volume = parseFloat(ev.currentTarget.value) / 100; - //props.updateSetting(setting); - }} - className="my-auto" - /> - {}} tooltip="Loop"> + {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: @@ -45,7 +92,7 @@ export function AudioSourcePanel(props: { source: AudioSource }) { return (
{sink.getName()} - + props.source.disconnect(sink)}>
); })} @@ -55,9 +102,15 @@ export function AudioSourcePanel(props: { source: AudioSource }) { .getSinks() .filter((sink) => !props.source.getConnectedTo().includes(sink)) .map((sink) => { - return { - props.source.connect(sink); - }}>{sink.getName()}; + return ( + { + props.source.connect(sink); + }} + > + {sink.getName()} + + ); })}
diff --git a/frontend/react/src/unit/unit.ts b/frontend/react/src/unit/unit.ts index d4232ae2..3a2d7076 100644 --- a/frontend/react/src/unit/unit.ts +++ b/frontend/react/src/unit/unit.ts @@ -851,6 +851,7 @@ export abstract class Unit extends CustomMarker { } ); + /* Temporarily removed until more work can be done for external sounds contextActionSet.addContextAction( this, "speaker", @@ -863,6 +864,7 @@ export abstract class Unit extends CustomMarker { }, { executeImmediately: true } ); + */ contextActionSet.addDefaultContextAction( this,