Initial Commit
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user