-
Notifications
You must be signed in to change notification settings - Fork 98
feat: add Audio Peak Level VU Meter & Clipping Warning #1159
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| import React, { useEffect, useRef, useState } from "react"; | ||
| import { Volume2, VolumeX, AlertTriangle, Activity } from "lucide-react"; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Prompt for AI agents |
||
|
|
||
| /** | ||
| * 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Clip threshold is inconsistent between Consider aligning the thresholds, e.g. Prompt for AI agents |
||
| setMaxPeakDb((prev) => Math.max(prev, level)); | ||
| return; | ||
| } | ||
|
Comment on lines
+26
to
+32
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π― Functional Correctness | π‘ Minor | β‘ Quick win Clip threshold differs between
Also applies to: 90-94 π§° Toolsπͺ ast-grep (0.44.1)[warning] 27-27: Avoid using the initial state variable in setState (setstate-same-var) π€ Prompt for AI Agents |
||
|
|
||
| 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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Passing an audio element already managed by Prompt for AI agents |
||
| const AudioContext = window.AudioContext || window.webkitAudioContext; | ||
| if (AudioContext) { | ||
| const ctx = new AudioContext(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: The AudioContext created in the Prompt for AI agents
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: An audio-element-backed meter can remain at silence when the context starts suspended because this effect never resumes it. Resuming from an allowed user/play event, or accepting a caller-owned running context/analyser, would make the optional audio path reliable. Prompt for AI agents |
||
| 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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: AudioNode references are stored directly on the DOM element via Prompt for AI agents |
||
| 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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P0: The VU meter shows simulated random data instead of actual audio levels when no audio source is connected. When Prompt for AI agents |
||
| // 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); | ||
|
Comment on lines
+74
to
+97
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π― Functional Correctness | π Major | ποΈ Heavy lift Meter always shows simulated random data, never real audio, when only When no See consolidated comment for the cross-file fix (wiring a real π§° Toolsπͺ ast-grep (0.44.1)[warning] 91-91: Avoid using the initial state variable in setState (setstate-same-var) [warning] 93-93: Avoid using the initial state variable in setState (setstate-same-var) π€ Prompt for AI Agents |
||
| }; | ||
|
|
||
| animationFrameRef.current = requestAnimationFrame(updateMeter); | ||
|
|
||
| return () => { | ||
| if (animationFrameRef.current) { | ||
| cancelAnimationFrame(animationFrameRef.current); | ||
| } | ||
| }; | ||
| }, [audioElementRef, analyserNode, isActive, testLevel]); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: If the referenced media element is mounted or replaced after this effect runs, the meter never reconnects because the ref object remains unchanged; with Prompt for AI agents |
||
|
|
||
| // 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() { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Prompt for AI agents |
||
| setMaxPeakDb(-60); | ||
| setIsClipping(false); | ||
| } | ||
|
|
||
| return ( | ||
| <div className={`rounded-lg border border-ink/10 bg-white p-3.5 shadow-soft dark:border-border dark:bg-surface dark:shadow-soft-dk ${className}`}> | ||
| <div className="flex items-center justify-between gap-2 mb-2"> | ||
| <div className="flex items-center gap-2"> | ||
| <Activity size={16} className={isClipping ? "text-red-500 animate-pulse" : "text-moss dark:text-glow"} aria-hidden="true" /> | ||
| <span className="text-xs font-bold uppercase tracking-wider text-ink dark:text-neutral-200"> | ||
| VU Peak Level Meter | ||
| </span> | ||
| </div> | ||
|
|
||
| {/* Digital Clipping Warning Indicator Badge */} | ||
| <div className="flex items-center gap-1.5"> | ||
| {isClipping ? ( | ||
| <div | ||
| role="alert" | ||
| aria-live="assertive" | ||
| className="inline-flex items-center gap-1 rounded bg-red-600 px-2 py-0.5 text-[11px] font-bold uppercase text-white shadow animate-bounce" | ||
| > | ||
| <AlertTriangle size={12} aria-hidden="true" /> | ||
| <span>CLIP WARNING!</span> | ||
| </div> | ||
| ) : ( | ||
| <span className="text-[11px] font-mono text-ink/60 dark:text-neutral-400"> | ||
| {peakDb <= -60 ? "-β dB" : `${peakDb.toFixed(1)} dB`} | ||
| </span> | ||
| )} | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* Meter Bar Container */} | ||
| <div className="relative h-4 w-full overflow-hidden rounded-full bg-ink/10 dark:bg-black/40 p-0.5 border border-ink/5 dark:border-border"> | ||
| {/* Animated Fill Bar with Color Gradient (Green -> Yellow -> Red) */} | ||
| <div | ||
| className="h-full rounded-full transition-all duration-75 bg-gradient-to-r from-emerald-500 via-amber-400 to-red-500" | ||
| style={{ width: `${percentage}%` }} | ||
| /> | ||
|
|
||
| {/* Max Peak Hold Indicator Line */} | ||
| {maxPeakDb > -60 && ( | ||
| <div | ||
| className="absolute top-0 bottom-0 w-0.5 bg-ink dark:bg-white shadow" | ||
| style={{ left: `${Math.max(0, Math.min(99, ((maxPeakDb + 60) / 60) * 100))}%` }} | ||
| title={`Max Peak: ${maxPeakDb.toFixed(1)} dB`} | ||
| /> | ||
| )} | ||
| </div> | ||
|
|
||
| {showLabels && ( | ||
| <div className="mt-1.5 flex justify-between text-[10px] font-mono text-ink/50 dark:text-neutral-400"> | ||
| <span>-60 dB</span> | ||
| <span>-36 dB</span> | ||
| <span>-18 dB</span> | ||
| <span>-6 dB</span> | ||
| <span className={isClipping ? "font-bold text-red-500" : ""}>0 dB</span> | ||
| </div> | ||
| )} | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The test only verifies that Prompt for AI agents |
||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"; | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||
| import { useToast, ToastContainer } from "../components/useToast.jsx"; | ||
|
|
||
| import { | ||
|
|
@@ -147,6 +149,11 @@ function Step2VoiceSettings({ onBack, onContinue }) { | |
| /> | ||
| </div> | ||
|
|
||
| {/* Audio Peak Level VU Meter & Clipping Warning */} | ||
| <div className="mt-5 pt-3 border-t border-ink/10 dark:border-border"> | ||
| <PeakLevelMeter isActive={true} /> | ||
| </div> | ||
|
|
||
|
Comment on lines
+152
to
+156
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π― Functional Correctness | π Major Meter mounted with no real audio source (see consolidated comment). π€ Prompt for AI Agents |
||
| {/* ββ Info note ββ */} | ||
| <p className="mt-4 text-xs text-ink/50 dark:text-muted"> | ||
| These values are also adjustable in the{" "} | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
π― Functional Correctness | π Major | ποΈ Heavy lift
The "real-time audio peak meter" never samples real audio in either page it's integrated into.
PeakLevelMeteronly samples actual audio when givenanalyserNodeoraudioElementRef; otherwise, wheneverisActiveis true it synthesizesmaxVal = 0.2 + Math.random() * 0.45every animation frame. Both integration sites pass onlyisActive={true}, so the meter permanently displays fabricated levels unrelated to any synthesized speech, defeating the linked issue's goal of visually surfacing real clipping (notably for users who can't rely on hearing).client/src/components/PeakLevelMeter.jsx#L74-97: this is the root cause β theelse if (isActive)random fallback should not silently stand in for real sampling; consider requiring/warning whenisActiveis set without a realanalyser, or clearly labeling the meter as "demo mode" in that case.client/src/pages/Onboarding.jsx#L152-156: pass a realanalyserNode/audioElementReftied to the TTS preview/test audio output instead of bareisActive={true}.client/src/pages/Settings.jsx#L325-332: same β wire this to the actual voice-synthesis preview audio element/analyser rather thanisActive={true}alone.π€ Prompt for AI Agents