import { type ReactNode, useEffect, useRef, useState } from "react"; import { useBus } from "../../hooks/use-bus/use-bus.js"; export interface MasterMeterProps { readonly className?: string; /** Name of the master bus to subscribe to. Defaults to `"master"`. */ readonly busName?: string; /** Number of channels to display. Defaults to 2. */ readonly channels?: number; /** Sample window in frames per second. Defaults to 60. */ readonly sampleRateHz?: number; /** Initial peak hold time in ms. Defaults to 1000. */ readonly peakHoldMs?: number; } interface MeterLevels { readonly peak: ReadonlyArray; readonly rms: ReadonlyArray; readonly hold: ReadonlyArray; } const EMPTY_LEVELS: MeterLevels = { peak: [], rms: [], hold: [] }; /** * RAF-sampled master output meter. Subscribes to the named bus on the * provider's `BusRegistry` (defaults to `"master"`) and reads block * samples on every interval tick, computing per-channel peak + RMS * levels in dB. A peak-hold envelope decays to the live peak after * `peakHoldMs`. When no publisher exists for the bus the meter shows * silence (-Infinity dB) on every channel. */ export function MasterMeter(props: MasterMeterProps): ReactNode { const { className, busName, channels, sampleRateHz, peakHoldMs } = props; const bus = useBus(busName ?? "master"); const channelCount = channels ?? 2; const sampleHz = Math.max(1, sampleRateHz ?? 60); const hold = Math.max(0, peakHoldMs ?? 1000); const [levels, setLevels] = useState(EMPTY_LEVELS); const holdRef = useRef<{ value: number; until: number }[]>([]); useEffect(() => { holdRef.current = Array.from({ length: channelCount }, () => ({ value: 0, until: 0, })); const buf: Float32Array[] = Array.from( { length: channelCount }, () => new Float32Array(128) ); const tick = () => { const now = Date.now(); const peak: number[] = new Array(channelCount).fill(0); const rms: number[] = new Array(channelCount).fill(0); readPeakAndRms(bus, channelCount, buf, peak, rms); const holdNext: number[] = updateHoldEnvelope( holdRef.current, peak, now, hold ); setLevels({ peak: peak.map(toDb), rms: rms.map(toDb), hold: holdNext.map(toDb), }); }; const interval = setInterval(tick, Math.floor(1000 / sampleHz)); return () => clearInterval(interval); }, [bus, channelCount, sampleHz, hold]); return (
{Array.from({ length: channelCount }, (_, c) => `ch-${c}`).map( (key, c) => { const p = levels.peak[c] ?? Number.NEGATIVE_INFINITY; const r = levels.rms[c] ?? Number.NEGATIVE_INFINITY; const h = levels.hold[c] ?? Number.NEGATIVE_INFINITY; return (
{p === Number.NEGATIVE_INFINITY ? "-∞" : `${p.toFixed(1)}`}{" "} dB {r === Number.NEGATIVE_INFINITY ? "-∞" : `${r.toFixed(1)}`}{" "} dB {h === Number.NEGATIVE_INFINITY ? "-∞" : `${h.toFixed(1)}`}{" "} dB
); } )}
); } function toDb(amp: number): number { if (amp <= 0) return Number.NEGATIVE_INFINITY; return 20 * Math.log10(amp); } function updateHoldEnvelope( holdRefs: ReadonlyArray<{ value: number; until: number }>, peak: ReadonlyArray, now: number, hold: number ): number[] { const out: number[] = new Array(holdRefs.length).fill(0); for (let c = 0; c < holdRefs.length; c++) { const entry = holdRefs[c]!; const exceeds = peak[c]! >= entry.value; const expired = now >= entry.until; if (exceeds || expired) { entry.value = peak[c]!; entry.until = now + hold; } out[c] = entry.value; } return out; } function readPeakAndRms( bus: { channels: number; read(into: Float32Array[]): void } | undefined, channelCount: number, buf: Float32Array[], peak: number[], rms: number[] ): void { if (bus === undefined) return; const wide = Math.min(channelCount, bus.channels); const into = buf.slice(0, wide); bus.read(into); for (let c = 0; c < wide; c++) { const arr = into[c]!; let p = 0; let sumSq = 0; for (let i = 0; i < arr.length; i++) { const v = arr[i]!; const absV = Math.abs(v); if (absV > p) p = absV; sumSq += v * v; } peak[c] = p; rms[c] = Math.sqrt(sumSq / arr.length); } }