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 (