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 ( +
+
+
+
+ + {/* Digital Clipping Warning Indicator Badge */} +
+ {isClipping ? ( +
+
+ ) : ( + + {peakDb <= -60 ? "-∞ dB" : `${peakDb.toFixed(1)} dB`} + + )} +
+
+ + {/* Meter Bar Container */} +
+ {/* Animated Fill Bar with Color Gradient (Green -> Yellow -> Red) */} +
+ + {/* Max Peak Hold Indicator Line */} + {maxPeakDb > -60 && ( +
+ )} +
+ + {showLabels && ( +
+ -60 dB + -36 dB + -18 dB + -6 dB + 0 dB +
+ )} +
+ ); +} diff --git a/client/src/components/PeakLevelMeter.test.js b/client/src/components/PeakLevelMeter.test.js new file mode 100644 index 00000000..b1b58d43 --- /dev/null +++ b/client/src/components/PeakLevelMeter.test.js @@ -0,0 +1,8 @@ +import { describe, it, expect } from "vitest"; +import { PeakLevelMeter } from "./PeakLevelMeter"; + +describe("PeakLevelMeter component", () => { + it("exports PeakLevelMeter function component", () => { + expect(typeof PeakLevelMeter).toBe("function"); + }); +}); diff --git a/client/src/pages/Onboarding.jsx b/client/src/pages/Onboarding.jsx index 365cd5a7..4b7c7a4a 100644 --- a/client/src/pages/Onboarding.jsx +++ b/client/src/pages/Onboarding.jsx @@ -3,6 +3,8 @@ import React from "react"; import { CheckCircle2, Loader2, CircleAlert, ArrowRight, RotateCcw } from "lucide-react"; import VoiceRecorder from "../components/VoiceRecorder.jsx"; import useVoiceClone from "../hooks/useVoiceClone.js"; +import { COLOR_TAGS, AVATAR_ICONS } from "../components/ProfileCard.jsx"; +import { PeakLevelMeter } from "../components/PeakLevelMeter.jsx"; import { useToast, ToastContainer } from "../components/useToast.jsx"; import { @@ -147,6 +149,11 @@ function Step2VoiceSettings({ onBack, onContinue }) { />
+ {/* Audio Peak Level VU Meter & Clipping Warning */} +
+ +
+ {/* ── Info note ── */}

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 */} +
+ +
{/* ── Language & Region ─────────────────────────────────────────── */}