168 lines
5.9 KiB
TypeScript
168 lines
5.9 KiB
TypeScript
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<number>;
|
|
readonly rms: ReadonlyArray<number>;
|
|
readonly hold: ReadonlyArray<number>;
|
|
}
|
|
|
|
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<MeterLevels>(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 (
|
|
<section
|
|
className={
|
|
className === undefined
|
|
? "rax-master-meter"
|
|
: `rax-master-meter ${className}`
|
|
}
|
|
data-bus={busName ?? "master"}
|
|
aria-label={`${busName ?? "master"} output meter`}
|
|
>
|
|
{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 (
|
|
<div
|
|
key={key}
|
|
className="rax-master-meter__channel"
|
|
data-channel={c}
|
|
>
|
|
<span className="rax-master-meter__peak">
|
|
{p === Number.NEGATIVE_INFINITY
|
|
? "-∞"
|
|
: `${p.toFixed(1)}`}{" "}
|
|
dB
|
|
</span>
|
|
<span className="rax-master-meter__rms">
|
|
{r === Number.NEGATIVE_INFINITY
|
|
? "-∞"
|
|
: `${r.toFixed(1)}`}{" "}
|
|
dB
|
|
</span>
|
|
<span className="rax-master-meter__hold">
|
|
{h === Number.NEGATIVE_INFINITY
|
|
? "-∞"
|
|
: `${h.toFixed(1)}`}{" "}
|
|
dB
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
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<number>,
|
|
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);
|
|
}
|
|
}
|