diff --git a/components/controls/MasterMeter.tsx b/components/controls/MasterMeter.tsx new file mode 100644 index 0000000..770268c --- /dev/null +++ b/components/controls/MasterMeter.tsx @@ -0,0 +1,120 @@ +'use client'; + +import * as React from 'react'; + +export interface MasterMeterProps { + /** Peak level (0-1) */ + peakLevel: number; + /** RMS level (0-1) */ + rmsLevel: number; + /** Whether clipping has occurred */ + isClipping: boolean; + /** Callback to reset clip indicator */ + onResetClip?: () => void; +} + +export function MasterMeter({ + peakLevel, + rmsLevel, + isClipping, + onResetClip, +}: MasterMeterProps) { + // Convert linear 0-1 to dB scale for display + const linearToDb = (linear: number): number => { + if (linear === 0) return -60; + const db = 20 * Math.log10(linear); + return Math.max(-60, Math.min(0, db)); + }; + + const peakDb = linearToDb(peakLevel); + const rmsDb = linearToDb(rmsLevel); + + // Calculate bar heights (0-100%) + const peakHeight = ((peakDb + 60) / 60) * 100; + const rmsHeight = ((rmsDb + 60) / 60) * 100; + + return ( +
+ {/* Clip Indicator */} + + + {/* Meters */} +
+ {/* Peak Meter (Left) */} +
+
+
-3 ? 'bg-red-500' : + peakDb > -6 ? 'bg-yellow-500' : + 'bg-green-500' + }`} /> +
+ {/* dB markers */} +
+
+
+
+
+
+
+
+ + {/* RMS Meter (Right) */} +
+
+
-3 ? 'bg-red-400' : + rmsDb > -6 ? 'bg-yellow-400' : + 'bg-green-400' + }`} /> +
+ {/* dB markers */} +
+
+
+
+
+
+
+
+
+ + {/* Labels and Values */} +
+
+ PK: + -3 ? 'text-red-500' : + peakDb > -6 ? 'text-yellow-500' : + 'text-green-500' + }`}> + {peakDb > -60 ? `${peakDb.toFixed(1)}` : '-∞'} + +
+
+ RM: + -3 ? 'text-red-400' : + rmsDb > -6 ? 'text-yellow-400' : + 'text-green-400' + }`}> + {rmsDb > -60 ? `${rmsDb.toFixed(1)}` : '-∞'} + +
+
dB
+
+
+ ); +} diff --git a/components/editor/AudioEditor.tsx b/components/editor/AudioEditor.tsx index e66d7ee..70c6417 100644 --- a/components/editor/AudioEditor.tsx +++ b/components/editor/AudioEditor.tsx @@ -219,6 +219,10 @@ export function AudioEditor() { currentTime, duration, trackLevels, + masterPeakLevel, + masterRmsLevel, + masterIsClipping, + resetClipIndicator, play, pause, stop, @@ -1056,6 +1060,10 @@ export function AudioEditor() { onPunchOutTimeChange={setPunchOutTime} overdubEnabled={overdubEnabled} onOverdubEnabledChange={setOverdubEnabled} + masterPeakLevel={masterPeakLevel} + masterRmsLevel={masterRmsLevel} + masterIsClipping={masterIsClipping} + onResetClip={resetClipIndicator} />
diff --git a/components/editor/PlaybackControls.tsx b/components/editor/PlaybackControls.tsx index 266922f..5580fa0 100644 --- a/components/editor/PlaybackControls.tsx +++ b/components/editor/PlaybackControls.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import { Play, Pause, Square, SkipBack, Volume2, VolumeX, Circle, AlignVerticalJustifyStart, AlignVerticalJustifyEnd, Layers } from 'lucide-react'; import { Button } from '@/components/ui/Button'; import { Slider } from '@/components/ui/Slider'; +import { MasterMeter } from '@/components/controls/MasterMeter'; import { cn } from '@/lib/utils/cn'; export interface PlaybackControlsProps { @@ -32,6 +33,10 @@ export interface PlaybackControlsProps { onPunchOutTimeChange?: (time: number) => void; overdubEnabled?: boolean; onOverdubEnabledChange?: (enabled: boolean) => void; + masterPeakLevel?: number; + masterRmsLevel?: number; + masterIsClipping?: boolean; + onResetClip?: () => void; } export function PlaybackControls({ @@ -60,6 +65,10 @@ export function PlaybackControls({ onPunchOutTimeChange, overdubEnabled = false, onOverdubEnabledChange, + masterPeakLevel = 0, + masterRmsLevel = 0, + masterIsClipping = false, + onResetClip, }: PlaybackControlsProps) { const [isMuted, setIsMuted] = React.useState(false); const [previousVolume, setPreviousVolume] = React.useState(volume); @@ -275,29 +284,40 @@ export function PlaybackControls({ )}
- {/* Volume Control */} -
- - - + {/* Master Meter */} + + + {/* Volume Control */} +
+ + + +
diff --git a/lib/hooks/useMultiTrackPlayer.ts b/lib/hooks/useMultiTrackPlayer.ts index 64ac2dd..2566063 100644 --- a/lib/hooks/useMultiTrackPlayer.ts +++ b/lib/hooks/useMultiTrackPlayer.ts @@ -29,6 +29,9 @@ export function useMultiTrackPlayer( const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [trackLevels, setTrackLevels] = useState>({}); + const [masterPeakLevel, setMasterPeakLevel] = useState(0); + const [masterRmsLevel, setMasterRmsLevel] = useState(0); + const [masterIsClipping, setMasterIsClipping] = useState(false); const audioContextRef = useRef(null); const sourceNodesRef = useRef([]); @@ -37,6 +40,8 @@ export function useMultiTrackPlayer( const analyserNodesRef = useRef([]); const effectNodesRef = useRef([]); // Effect nodes per track const masterGainNodeRef = useRef(null); + const masterAnalyserRef = useRef(null); + const masterLevelMonitorFrameRef = useRef(null); const startTimeRef = useRef(0); const pausedAtRef = useRef(0); const animationFrameRef = useRef(null); @@ -116,6 +121,46 @@ export function useMultiTrackPlayer( levelMonitorFrameRef.current = requestAnimationFrame(monitorPlaybackLevels); }, []); + // Monitor master output levels (peak and RMS) + const monitorMasterLevels = useCallback(() => { + if (!masterAnalyserRef.current) { + return; + } + + const analyser = masterAnalyserRef.current; + const bufferLength = analyser.fftSize; + const dataArray = new Float32Array(bufferLength); + + analyser.getFloatTimeDomainData(dataArray); + + // Calculate peak level (max absolute value) + let peak = 0; + for (let i = 0; i < bufferLength; i++) { + const abs = Math.abs(dataArray[i]); + if (abs > peak) { + peak = abs; + } + } + + // Calculate RMS level (root mean square) + let sumSquares = 0; + for (let i = 0; i < bufferLength; i++) { + sumSquares += dataArray[i] * dataArray[i]; + } + const rms = Math.sqrt(sumSquares / bufferLength); + + // Detect clipping (signal >= 1.0) + const isClipping = peak >= 1.0; + + setMasterPeakLevel(peak); + setMasterRmsLevel(rms); + if (isClipping) { + setMasterIsClipping(true); + } + + masterLevelMonitorFrameRef.current = requestAnimationFrame(monitorMasterLevels); + }, []); + // Apply automation values during playback const applyAutomation = useCallback(() => { if (!audioContextRef.current) return; @@ -303,11 +348,20 @@ export function useMultiTrackPlayer( analyserNodesRef.current = []; effectNodesRef.current = []; - // Create master gain node + // Create master gain node with analyser for metering const masterGain = audioContext.createGain(); masterGain.gain.setValueAtTime(masterVolume, audioContext.currentTime); - masterGain.connect(audioContext.destination); + + const masterAnalyser = audioContext.createAnalyser(); + masterAnalyser.fftSize = 256; + masterAnalyser.smoothingTimeConstant = 0.8; + + // Connect: masterGain -> analyser -> destination + masterGain.connect(masterAnalyser); + masterAnalyser.connect(audioContext.destination); + masterGainNodeRef.current = masterGain; + masterAnalyserRef.current = masterAnalyser; // Create audio graph for each track for (const track of tracks) { @@ -376,10 +430,11 @@ export function useMultiTrackPlayer( // Start level monitoring isMonitoringLevelsRef.current = true; monitorPlaybackLevels(); + monitorMasterLevels(); // Start automation applyAutomation(); - }, [tracks, duration, masterVolume, updatePlaybackPosition, monitorPlaybackLevels, applyAutomation]); + }, [tracks, duration, masterVolume, updatePlaybackPosition, monitorPlaybackLevels, monitorMasterLevels, applyAutomation]); const pause = useCallback(() => { if (!audioContextRef.current || !isPlaying) return; @@ -414,6 +469,11 @@ export function useMultiTrackPlayer( levelMonitorFrameRef.current = null; } + if (masterLevelMonitorFrameRef.current) { + cancelAnimationFrame(masterLevelMonitorFrameRef.current); + masterLevelMonitorFrameRef.current = null; + } + if (automationFrameRef.current) { cancelAnimationFrame(automationFrameRef.current); automationFrameRef.current = null; @@ -650,6 +710,10 @@ export function useMultiTrackPlayer( cancelAnimationFrame(levelMonitorFrameRef.current); levelMonitorFrameRef.current = null; } + if (masterLevelMonitorFrameRef.current) { + cancelAnimationFrame(masterLevelMonitorFrameRef.current); + masterLevelMonitorFrameRef.current = null; + } if (automationFrameRef.current) { cancelAnimationFrame(automationFrameRef.current); automationFrameRef.current = null; @@ -662,12 +726,13 @@ export function useMultiTrackPlayer( }; updatePosition(); monitorPlaybackLevels(); + monitorMasterLevels(); applyAutomation(); }, 10); } previousEffectStructureRef.current = currentStructure; - }, [tracks, isPlaying, duration, masterVolume, monitorPlaybackLevels, applyAutomation]); + }, [tracks, isPlaying, duration, masterVolume, monitorPlaybackLevels, monitorMasterLevels, applyAutomation]); // Stop playback when all tracks are deleted useEffect(() => { @@ -730,6 +795,9 @@ export function useMultiTrackPlayer( if (levelMonitorFrameRef.current) { cancelAnimationFrame(levelMonitorFrameRef.current); } + if (masterLevelMonitorFrameRef.current) { + cancelAnimationFrame(masterLevelMonitorFrameRef.current); + } if (automationFrameRef.current) { cancelAnimationFrame(automationFrameRef.current); } @@ -750,11 +818,19 @@ export function useMultiTrackPlayer( }; }, []); + const resetClipIndicator = useCallback(() => { + setMasterIsClipping(false); + }, []); + return { isPlaying, currentTime, duration, trackLevels, + masterPeakLevel, + masterRmsLevel, + masterIsClipping, + resetClipIndicator, play, pause, stop,