Fiber UI LogoFiberUI

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 isSpeaking state
  • 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 &quot;Speaking&quot;.
                    </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

PropertyTypeDefaultDescription
speakingThresholdnumber0.01Level (0-1) above which isSpeaking is true
sampleIntervalnumber100How often to sample in milliseconds
smoothingFactornumber0.8Smoothing for level transitions (0-1)
autoStartbooleantrueAuto-start when stream is available

Return Value

PropertyTypeDescription
levelnumberCurrent audio level (0-1)
isSpeakingbooleanWhether level exceeds threshold
peaknumberPeak level since last reset
resetPeak() => voidReset peak level
start() => voidStart monitoring
stop() => voidStop monitoring
isMonitoringbooleanWhether currently monitoring
isSupportedbooleanWhether 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;