useAudioLevel
A hook for detecting audio volume levels
A React hook for detecting audio volume from a media stream. Uses the Web Audio API to analyze audio levels for speaking indicators and visualizations.
Source Code
View the full hook implementation in the Hook Source Code section below.
Features
- Volume Detection - Real-time audio level (0-1 scale)
- Speaking Detection - Threshold-based
isSpeakingstate - Peak Tracking - Track peak level with reset
- Configurable - Adjust threshold, sample rate, smoothing
- Auto Start - Automatically starts monitoring when stream is available
Volume Meter
Bar graph visualization with speaking detection:
Silent
Not monitoring
"use client";
import { useUserMedia } from "@repo/hooks/webrtc/use-user-media";
import { useAudioLevel } from "@repo/hooks/webrtc/use-audio-level";
import { Button } from "@repo/ui/components/button";
import { Mic, Activity } from "lucide-react";
/* VOLUME METER - Real-time Audio Level Visualization */
export const Example1 = () => {
const { stream, isActive, start, stop } = useUserMedia({
constraints: { audio: true, video: false },
});
const { level, isSpeaking, peak, resetPeak, isMonitoring } =
useAudioLevel(stream);
const volumePercent = Math.round(level * 100);
const peakPercent = Math.round(peak * 100);
// Generate bars for visualization
const bars = Array.from({ length: 20 }, (_, i) => {
const threshold = (i + 1) / 20;
const isActive = level >= threshold;
const isPeak = peak >= threshold && peak < threshold + 0.05;
return { isActive, isPeak, threshold };
});
return (
<div className="flex w-full max-w-sm flex-col gap-4">
{/* Status Card */}
<div
className={`flex items-center gap-3 rounded-lg border p-4 transition-all ${
isSpeaking
? "border-green-500/50 bg-green-500/10"
: "border-border"
}`}
>
<div
className={`flex h-12 w-12 items-center justify-center rounded-full transition-all ${
isSpeaking ? "scale-110 bg-green-500" : "bg-muted"
}`}
>
{isSpeaking ? (
<Activity className="h-6 w-6 animate-pulse text-white" />
) : (
<Mic className="text-muted-foreground h-6 w-6" />
)}
</div>
<div>
<div className="font-medium">
{isSpeaking ? "Speaking" : "Silent"}
</div>
<div className="text-muted-foreground text-sm">
{isMonitoring
? `Volume: ${volumePercent}%`
: "Not monitoring"}
</div>
</div>
</div>
{/* Bar Graph Visualization */}
{isMonitoring && (
<div className="flex h-16 items-end justify-center gap-0.5">
{bars.map((bar, i) => (
<div
key={i}
className={`w-2 rounded-t transition-all duration-75 ${
bar.isPeak
? "bg-red-500"
: bar.isActive
? i < 14
? "bg-green-500"
: i < 17
? "bg-yellow-500"
: "bg-red-500"
: "bg-zinc-300 dark:bg-zinc-700"
}`}
style={{
height: `${((i + 1) / 20) * 64}px`,
}}
/>
))}
</div>
)}
{/* Stats */}
{isMonitoring && (
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">
Peak: {peakPercent}%
</span>
<button
onClick={resetPeak}
className="text-muted-foreground hover:text-foreground underline"
>
Reset
</button>
</div>
)}
{/* Controls */}
<div className="flex justify-center">
{isActive ? (
<Button variant="destructive" onClick={stop}>
Stop
</Button>
) : (
<Button
onClick={() => start({ audio: true, video: false })}
>
Start Microphone
</Button>
)}
</div>
</div>
);
};
Speaking Indicator Ring
Green ring around avatar when speaking:
You
Offline
"use client";
import { useRef, useEffect } from "react";
import { useUserMedia } from "@repo/hooks/webrtc/use-user-media";
import { useAudioLevel } from "@repo/hooks/webrtc/use-audio-level";
import { Button } from "@repo/ui/components/button";
import { VideoOff } from "lucide-react";
/* SPEAKING INDICATOR RING - Green Ring Around Video */
export const Example2 = () => {
const videoRef = useRef<HTMLVideoElement>(null);
const { stream, isActive, start, stop } = useUserMedia();
const { level, isSpeaking } = useAudioLevel(stream, {
speakingThreshold: 0.02,
smoothingFactor: 0.7,
});
// Attach stream
useEffect(() => {
if (videoRef.current && stream) {
videoRef.current.srcObject = stream;
}
}, [stream]);
// Ring scale based on audio level
const ringScale = 1 + level * 0.3;
return (
<div className="flex w-full max-w-xs flex-col items-center gap-4">
{/* Avatar with Speaking Ring */}
<div className="relative">
{/* Animated Ring */}
<div
className={`absolute -inset-2 rounded-full transition-all duration-100 ${
isSpeaking
? "bg-linear-to-r from-green-400 to-emerald-500 opacity-100"
: "opacity-0"
}`}
style={{
transform: `scale(${ringScale})`,
}}
/>
{/* Video Container */}
<div className="border-background relative h-48 w-48 overflow-hidden rounded-full border-4 bg-zinc-900">
{isActive ? (
<video
ref={videoRef}
autoPlay
playsInline
muted
className="h-full w-full scale-x-[-1] object-cover"
/>
) : (
<div className="flex h-full items-center justify-center">
<VideoOff className="h-12 w-12 text-zinc-600" />
</div>
)}
</div>
{/* Speaking Badge */}
{isSpeaking && (
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2 rounded-full bg-green-500 px-3 py-1 text-xs font-medium text-white shadow-lg">
Speaking
</div>
)}
</div>
{/* Name */}
<div className="text-center">
<div className="font-medium">You</div>
<div className="text-muted-foreground text-sm">
{isActive
? isSpeaking
? "Speaking..."
: "Listening"
: "Offline"}
</div>
</div>
{/* Control */}
<Button
onClick={isActive ? stop : () => start()}
variant={isActive ? "destructive" : "default"}
>
{isActive ? "Leave" : "Join"}
</Button>
</div>
);
};
Threshold Configuration
Adjust sensitivity settings:
Silent
0%Configuration
2%
Red line shows threshold. Voice must exceed it to trigger "Speaking".
100ms
Lower = more responsive, higher = less CPU usage.
"use client";
import { useState } from "react";
import { useUserMedia } from "@repo/hooks/webrtc/use-user-media";
import { useAudioLevel } from "@repo/hooks/webrtc/use-audio-level";
import { Button } from "@repo/ui/components/button";
import { Mic, Settings } from "lucide-react";
/* THRESHOLD CONFIGURATION - Adjust Sensitivity */
export const Example3 = () => {
const [threshold, setThreshold] = useState(0.02);
const [interval, setInterval] = useState(100);
const { stream, isActive, start, stop } = useUserMedia({
constraints: { audio: true, video: false },
});
const { level, isSpeaking, isMonitoring } = useAudioLevel(stream, {
speakingThreshold: threshold,
sampleInterval: interval,
});
const volumePercent = Math.round(level * 100);
return (
<div className="flex w-full max-w-sm flex-col gap-4">
{/* Status */}
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="flex items-center gap-3">
<div
className={`h-4 w-4 rounded-full ${
isSpeaking
? "animate-pulse bg-green-500"
: "bg-zinc-400"
}`}
/>
<span className="font-medium">
{isSpeaking ? "Speaking" : "Silent"}
</span>
</div>
<span className="text-muted-foreground font-mono text-sm">
{volumePercent}%
</span>
</div>
{/* Level Bar */}
{isMonitoring && (
<div className="relative h-4 overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-800">
{/* Level */}
<div
className={`absolute inset-y-0 left-0 transition-all duration-75 ${
isSpeaking ? "bg-green-500" : "bg-zinc-400"
}`}
style={{ width: `${volumePercent}%` }}
/>
{/* Threshold Marker */}
<div
className="absolute inset-y-0 w-0.5 bg-red-500"
style={{ left: `${threshold * 100}%` }}
/>
</div>
)}
{/* Configuration */}
<div className="rounded-lg border p-4">
<div className="mb-3 flex items-center gap-2 text-sm font-medium">
<Settings className="h-4 w-4" />
Configuration
</div>
{/* Threshold Slider */}
<div className="mb-4">
<div className="mb-1 flex justify-between text-sm">
<label className="text-muted-foreground">
Speaking Threshold
</label>
<span className="font-mono">
{Math.round(threshold * 100)}%
</span>
</div>
<input
type="range"
min="0.005"
max="0.2"
step="0.005"
value={threshold}
onChange={(e) =>
setThreshold(parseFloat(e.target.value))
}
className="w-full"
/>
<div className="text-muted-foreground mt-1 text-xs">
Red line shows threshold. Voice must exceed it to
trigger "Speaking".
</div>
</div>
{/* Sample Interval */}
<div>
<div className="mb-1 flex justify-between text-sm">
<label className="text-muted-foreground">
Sample Interval
</label>
<span className="font-mono">{interval}ms</span>
</div>
<input
type="range"
min="50"
max="500"
step="50"
value={interval}
onChange={(e) => setInterval(parseInt(e.target.value))}
className="w-full"
/>
<div className="text-muted-foreground mt-1 text-xs">
Lower = more responsive, higher = less CPU usage.
</div>
</div>
</div>
{/* Controls */}
<div className="flex justify-center">
{isActive ? (
<Button variant="destructive" onClick={stop}>
Stop
</Button>
) : (
<Button
onClick={() => start({ audio: true, video: false })}
className="gap-2"
>
<Mic className="h-4 w-4" />
Start Microphone
</Button>
)}
</div>
</div>
);
};
API Reference
Hook Signature
function useAudioLevel(
stream: MediaStream | null,
options?: UseAudioLevelOptions,
): UseAudioLevelReturn;Options
| Property | Type | Default | Description |
|---|---|---|---|
speakingThreshold | number | 0.01 | Level (0-1) above which isSpeaking is true |
sampleInterval | number | 100 | How often to sample in milliseconds |
smoothingFactor | number | 0.8 | Smoothing for level transitions (0-1) |
autoStart | boolean | true | Auto-start when stream is available |
Return Value
| Property | Type | Description |
|---|---|---|
level | number | Current audio level (0-1) |
isSpeaking | boolean | Whether level exceeds threshold |
peak | number | Peak level since last reset |
resetPeak | () => void | Reset peak level |
start | () => void | Start monitoring |
stop | () => void | Stop monitoring |
isMonitoring | boolean | Whether currently monitoring |
isSupported | boolean | Whether Web Audio API is supported |
Hook Source Code
import { useState, useEffect, useRef, useCallback } from "react";
/**
* Options for the useAudioLevel hook
*/
export interface UseAudioLevelOptions {
/** Threshold (0-1) above which isSpeaking is true (default: 0.01) */
speakingThreshold?: number;
/** How often to sample audio level in ms (default: 100) */
sampleInterval?: number;
/** Smoothing factor for level transitions (0-1, default: 0.8) */
smoothingFactor?: number;
/** Whether to start monitoring automatically (default: true) */
autoStart?: boolean;
}
/**
* Return type for the useAudioLevel hook
*/
export interface UseAudioLevelReturn {
/** Current audio level (0-1) */
level: number;
/** Whether audio exceeds the speaking threshold */
isSpeaking: boolean;
/** Peak audio level since last reset */
peak: number;
/** Reset peak level */
resetPeak: () => void;
/** Start monitoring audio */
start: () => void;
/** Stop monitoring audio */
stop: () => void;
/** Whether currently monitoring */
isMonitoring: boolean;
/** Whether Web Audio API is supported */
isSupported: boolean;
}
/**
* A React hook for detecting audio volume from a media stream.
* Uses Web Audio API to analyze audio levels for speaking indicators.
*
* @param stream - The MediaStream to monitor (must have audio tracks)
* @param options - Configuration options
* @returns UseAudioLevelReturn object with level, speaking state, and controls
*
* @example
* ```tsx
* const { stream } = useUserMedia({ audio: true });
* const { level, isSpeaking } = useAudioLevel(stream);
*
* return (
* <div>
* <div
* className="speaking-indicator"
* style={{
* opacity: isSpeaking ? 1 : 0.3,
* transform: `scale(${1 + level * 0.5})`,
* }}
* />
* <p>Volume: {Math.round(level * 100)}%</p>
* </div>
* );
* ```
*/
export function useAudioLevel(
stream: MediaStream | null,
options: UseAudioLevelOptions = {},
): UseAudioLevelReturn {
const {
speakingThreshold = 0.01,
sampleInterval = 100,
smoothingFactor = 0.8,
autoStart = true,
} = options;
const [level, setLevel] = useState(0);
const [peak, setPeak] = useState(0);
const [isMonitoring, setIsMonitoring] = useState(false);
// Refs for audio context and nodes
const audioContextRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
const animationFrameRef = useRef<number | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const smoothedLevelRef = useRef(0);
// Check if Web Audio API is supported
const isSupported =
typeof window !== "undefined" &&
("AudioContext" in window || "webkitAudioContext" in window);
// Calculate RMS (Root Mean Square) level from audio data
const calculateLevel = useCallback((): number => {
if (!analyserRef.current) return 0;
const analyser = analyserRef.current;
const dataArray = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteTimeDomainData(dataArray);
// Calculate RMS
let sum = 0;
for (let i = 0; i < dataArray.length; i++) {
const value = dataArray[i] ?? 128;
const normalized = (value - 128) / 128;
sum += normalized * normalized;
}
const rms = Math.sqrt(sum / dataArray.length);
return Math.min(1, rms * 2); // Scale up and clamp
}, []);
// Start monitoring
const start = useCallback(() => {
if (!stream || !isSupported || isMonitoring) return;
const audioTrack = stream.getAudioTracks()[0];
if (!audioTrack) return;
try {
// Create audio context
const AudioContextClass =
window.AudioContext ||
(
window as unknown as {
webkitAudioContext: typeof AudioContext;
}
).webkitAudioContext;
const audioContext = new AudioContextClass();
audioContextRef.current = audioContext;
// Create analyser node
const analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = smoothingFactor;
analyserRef.current = analyser;
// Create source from stream
const source = audioContext.createMediaStreamSource(stream);
source.connect(analyser);
sourceRef.current = source;
// Start sampling at interval
intervalRef.current = setInterval(() => {
const rawLevel = calculateLevel();
// Apply smoothing
smoothedLevelRef.current =
smoothedLevelRef.current * smoothingFactor +
rawLevel * (1 - smoothingFactor);
const currentLevel = smoothedLevelRef.current;
setLevel(currentLevel);
// Update peak
if (currentLevel > peak) {
setPeak(currentLevel);
}
}, sampleInterval);
setIsMonitoring(true);
} catch (err) {
console.error("Failed to start audio monitoring:", err);
}
}, [
stream,
isSupported,
isMonitoring,
sampleInterval,
smoothingFactor,
calculateLevel,
peak,
]);
// Stop monitoring
const stop = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (sourceRef.current) {
sourceRef.current.disconnect();
sourceRef.current = null;
}
if (audioContextRef.current) {
audioContextRef.current.close().catch(() => {});
audioContextRef.current = null;
}
analyserRef.current = null;
smoothedLevelRef.current = 0;
setLevel(0);
setIsMonitoring(false);
}, []);
// Reset peak
const resetPeak = useCallback(() => {
setPeak(0);
}, []);
// Auto-start when stream changes, stop when stream is null
useEffect(() => {
// If stream is null or undefined, stop monitoring
if (!stream) {
if (isMonitoring) {
// Clean up manually instead of calling stop to avoid dependency issues
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (sourceRef.current) {
sourceRef.current.disconnect();
sourceRef.current = null;
}
if (audioContextRef.current) {
audioContextRef.current.close().catch(() => {});
audioContextRef.current = null;
}
analyserRef.current = null;
smoothedLevelRef.current = 0;
setLevel(0);
setIsMonitoring(false);
}
return;
}
// If stream exists and autoStart is enabled, start monitoring
if (autoStart && !isMonitoring) {
start();
}
// Cleanup on stream change
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (sourceRef.current) {
sourceRef.current.disconnect();
sourceRef.current = null;
}
if (audioContextRef.current) {
audioContextRef.current.close().catch(() => {});
audioContextRef.current = null;
}
analyserRef.current = null;
smoothedLevelRef.current = 0;
setLevel(0);
setIsMonitoring(false);
};
}, [stream, autoStart]); // eslint-disable-line react-hooks/exhaustive-deps
// Cleanup on unmount
useEffect(() => {
return () => {
stop();
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return {
level,
isSpeaking: level > speakingThreshold,
peak,
resetPeak,
start,
stop,
isMonitoring,
isSupported,
};
}
export default useAudioLevel;