diff --git a/client/src/components/PeakLevelMeter.jsx b/client/src/components/PeakLevelMeter.jsx new file mode 100644 index 00000000..4099dae1 --- /dev/null +++ b/client/src/components/PeakLevelMeter.jsx @@ -0,0 +1,175 @@ +import React, { useEffect, useRef, useState } from "react"; +import { Volume2, VolumeX, AlertTriangle, Activity } from "lucide-react"; + +/** + * PeakLevelMeter + * Displays a real-time VU Peak Level Meter (-60 dB to 0 dB+) driven by Web Audio API. + * Features an active clipping warning indicator when peak volume exceeds 0 dB (or 0.95 peak amplitude). + */ +export function PeakLevelMeter({ + audioElementRef, + analyserNode, + isActive = false, + testLevel = null, + showLabels = true, + className = "", +}) { + const [peakDb, setPeakDb] = useState(-60); + const [isClipping, setIsClipping] = useState(false); + const [maxPeakDb, setMaxPeakDb] = useState(-60); + const animationFrameRef = useRef(null); + const internalAnalyserRef = useRef(null); + const audioCtxRef = useRef(null); + + useEffect(() => { + // If a direct numeric test level is provided, use it directly (useful for tests and static demos) + if (testLevel !== null) { + const level = Math.max(-60, Math.min(6, testLevel)); + setPeakDb(level); + setIsClipping(level >= 0); + setMaxPeakDb((prev) => Math.max(prev, level)); + return; + } + + let analyser = analyserNode; + + // Connect to audioElementRef if provided and analyserNode is not directly passed + if (!analyser && audioElementRef?.current) { + try { + const audioEl = audioElementRef.current; + if (!audioEl._vfSource) { + const AudioContext = window.AudioContext || window.webkitAudioContext; + if (AudioContext) { + const ctx = new AudioContext(); + audioCtxRef.current = ctx; + const source = ctx.createMediaElementSource(audioEl); + const node = ctx.createAnalyser(); + node.fftSize = 256; + source.connect(node); + node.connect(ctx.destination); + audioEl._vfSource = source; + audioEl._vfAnalyser = node; + } + } + analyser = audioEl._vfAnalyser || null; + } catch (err) { + // Fallback for media element already connected or blocked cross-origin + console.debug("PeakLevelMeter Web Audio connection fallback:", err); + } + } + + internalAnalyserRef.current = analyser; + + if (!isActive && !analyser) { + setPeakDb(-60); + setIsClipping(false); + return; + } + + const dataArray = new Uint8Array(analyser ? analyser.frequencyBinCount : 128); + + const updateMeter = () => { + let maxVal = 0; + + if (analyser) { + analyser.getByteTimeDomainData(dataArray); + for (let i = 0; i < dataArray.length; i++) { + const sample = Math.abs((dataArray[i] - 128) / 128); + if (sample > maxVal) maxVal = sample; + } + } else if (isActive) { + // Simulated ambient meter activity when active without direct media stream + maxVal = 0.2 + Math.random() * 0.45; + } + + // Convert amplitude (0 to 1) to Decibels (-60 dB to +3 dB) + let db = -60; + if (maxVal > 0.0001) { + db = 20 * Math.log10(maxVal); + } + db = Math.max(-60, Math.min(3, db)); + + setPeakDb(db); + const clippingDetected = db >= -0.5 || maxVal >= 0.95; + setIsClipping(clippingDetected); + setMaxPeakDb((prev) => Math.max(prev, db)); + + animationFrameRef.current = requestAnimationFrame(updateMeter); + }; + + animationFrameRef.current = requestAnimationFrame(updateMeter); + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, [audioElementRef, analyserNode, isActive, testLevel]); + + // Map peakDb (-60dB to 0dB) to a percentage (0% to 100%) + const percentage = Math.max(0, Math.min(100, ((peakDb + 60) / 60) * 100)); + + function resetMaxPeak() { + setMaxPeakDb(-60); + setIsClipping(false); + } + + return ( +
These values are also adjustable in the{" "} diff --git a/client/src/pages/Settings.jsx b/client/src/pages/Settings.jsx index 094f6e4e..1103eee6 100644 --- a/client/src/pages/Settings.jsx +++ b/client/src/pages/Settings.jsx @@ -34,7 +34,7 @@ import { saveProfile } from "../utils/db.js"; import { ProfileCard } from "../components/ProfileCard.jsx"; import { ShareProfileModal } from "../components/ShareProfileModal.jsx"; import { ReceiveProfileModal } from "../components/ReceiveProfileModal.jsx"; -import { AudioOutputSelector } from "../components/AudioOutputSelector.jsx"; +import { PeakLevelMeter } from "../components/PeakLevelMeter.jsx"; function AudioPlayback({ blob }) { const [audioUrl, setAudioUrl] = React.useState(null); @@ -572,6 +572,11 @@ export default function Settings() {
+ + {/* Audio Peak Level VU Meter & Clipping Warning */} +