Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,8 @@ Useful props:
- `backgroundColor`: fill color. Defaults to `#10110f`.
- `barColor`: live spectrum bar color. Defaults to `#c8ea3a`.
- `idleBarColor`: idle bar color. Defaults to `barColor`.
- `scale`: `"log"` or `"linear"`. Defaults to `"log"`. Log spreads bars across
the full audible range; linear reads one consecutive FFT bin per bar.
- `barCount`: number of rendered bars. Defaults to `48`.
- `barGap`: gap between bars in canvas pixels. Defaults to `2`.
- `minBarHeight`: minimum visible bar height. Defaults to `2`.
Expand Down
30 changes: 30 additions & 0 deletions packages/react/src/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
WaveformCanvas,
createDefaultAudioTestModeSteps,
type PlaybackHandle,
readSpectrumBarValue,
useAnalyser,
useAudioContext,
useAudioEngine,
Expand Down Expand Up @@ -702,6 +703,35 @@ function createCanvasContext() {
} as unknown as CanvasRenderingContext2D;
}

describe("readSpectrumBarValue", () => {
test("returns zero for missing or empty data", () => {
expect(readSpectrumBarValue(null, 0, 48, "log")).toBe(0);
expect(readSpectrumBarValue(new Uint8Array(0), 0, 48, "log")).toBe(0);
});

test("reads one bin per bar on the linear scale", () => {
const data = Uint8Array.from([10, 20, 30, 40]);
expect(readSpectrumBarValue(data, 0, 4, "linear")).toBe(10);
expect(readSpectrumBarValue(data, 2, 4, "linear")).toBe(30);
});

test("takes the peak of log-mapped bins and covers the top of the range", () => {
const data = new Uint8Array(1024);
data[900] = 200;
// A high bin lands in the last bar on a log axis but is hidden on a small
// linear read that only covers the first `barCount` bins.
expect(readSpectrumBarValue(data, 47, 48, "log")).toBeGreaterThan(0);
expect(readSpectrumBarValue(data, 47, 48, "linear")).toBe(0);
});

test("keeps log bar ranges within the data bounds", () => {
const data = new Uint8Array(32).fill(128);
for (let index = 0; index < 48; index += 1) {
expect(readSpectrumBarValue(data, index, 48, "log")).toBe(128);
}
});
});

describe("AudioProvider", () => {
test("throws a clear error when hooks are used outside AudioProvider", () => {
expect(() => render(<MissingProviderHarness />)).toThrow(
Expand Down
54 changes: 52 additions & 2 deletions packages/react/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,11 @@ export type SpectrumCanvasProps = Omit<
idleBarColor?: string;
minBarHeight?: number;
motion?: CanvasMotionMode;
scale?: SpectrumScale;
};

export type SpectrumScale = "linear" | "log";

export type AudioTestModeStep =
| {
description: string;
Expand Down Expand Up @@ -1148,6 +1151,42 @@ export function WaveformCanvas({
);
}

export function readSpectrumBarValue(
data: Uint8Array | null,
index: number,
barCount: number,
scale: SpectrumScale,
): number {
if (!data || data.length === 0) {
return 0;
}
if (scale === "linear") {
return data[index] ?? 0;
}
// Log scale: map bar [index, index + 1) across bins [1, length)
// logarithmically and take the peak of the covered bins. Bin index is
// linear in frequency, so a log spread over bins gives a log-frequency axis.
const minBin = 1;
const maxBin = Math.max(minBin + 1, data.length);
const ratio = maxBin / minBin;
const lo = Math.min(
data.length - 1,
Math.max(0, Math.floor(minBin * ratio ** (index / barCount))),
);
const hi = Math.min(
data.length,
Math.max(lo + 1, Math.ceil(minBin * ratio ** ((index + 1) / barCount))),
);
let peak = 0;
for (let bin = lo; bin < hi; bin += 1) {
const value = data[bin] ?? 0;
if (value > peak) {
peak = value;
}
}
return peak;
}

export function SpectrumCanvas({
backgroundColor = "#10110f",
barColor = "#c8ea3a",
Expand All @@ -1157,6 +1196,7 @@ export function SpectrumCanvas({
idleBarColor,
minBarHeight = 2,
motion = "auto",
scale = "log",
width = 720,
...canvasProps
}: SpectrumCanvasProps) {
Expand Down Expand Up @@ -1184,6 +1224,8 @@ export function SpectrumCanvas({
const normalizedBarCount = normalizeBarCount(barCount);
const normalizedBarGap = normalizeCanvasNumber(barGap, 2);
const normalizedMinBarHeight = normalizeCanvasNumber(minBarHeight, 2);
const normalizedScale: SpectrumScale =
scale === "linear" ? "linear" : "log";
const drawBars = (data: Uint8Array | null) => {
const ratio = getDevicePixelRatio();
const scaledBarGap = normalizedBarGap * ratio;
Expand All @@ -1198,7 +1240,12 @@ export function SpectrumCanvas({
);

for (let index = 0; index < normalizedBarCount; index += 1) {
const value = data?.[index] ?? 0;
const value = readSpectrumBarValue(
data,
index,
normalizedBarCount,
normalizedScale,
);
const normalizedValue = value / 255;
const barHeight = Math.max(
scaledMinBarHeight,
Expand All @@ -1216,7 +1263,9 @@ export function SpectrumCanvas({
}

const data = new Uint8Array(
Math.min(normalizedBarCount, analyser.frequencyBinCount),
normalizedScale === "log"
? analyser.frequencyBinCount
: Math.min(normalizedBarCount, analyser.frequencyBinCount),
);
let frame = 0;

Expand Down Expand Up @@ -1272,6 +1321,7 @@ export function SpectrumCanvas({
idleBarColor,
minBarHeight,
motion,
scale,
width,
]);

Expand Down