UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: фильтры активности и единый voice loader

This commit is contained in:
DCCONSTRUCTIONS
2026-04-26 21:17:23 +03:00
parent 9a91af372e
commit 7ac9a3dbd3
10 changed files with 509 additions and 40 deletions
@@ -4,9 +4,10 @@
* See the LICENSE file for details.
*/
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { BProgress } from "@bprogress/core";
import { useNavigation } from "react-router";
import { NodedcProcessingLoader } from "@/components/common/nodedc-processing-loader";
import "@bprogress/core/css";
/**
@@ -66,10 +67,11 @@ const PROGRESS_CONFIG: Readonly<ProgressConfig> = {
* }
* ```
*/
export default function AppProgressBar(): null {
export default function AppProgressBar() {
const navigation = useNavigation();
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const startedRef = useRef<boolean>(false);
const [isLoaderVisible, setIsLoaderVisible] = useState(false);
// Initialize BProgress once on mount
useEffect(() => {
@@ -118,6 +120,7 @@ export default function AppProgressBar(): null {
BProgress.done();
startedRef.current = false;
}
setIsLoaderVisible(false);
} else {
// Navigation in progress (loading or submitting)
// Only start if not already started and no timer pending
@@ -127,6 +130,7 @@ export default function AppProgressBar(): null {
BProgress.start();
startedRef.current = true;
}
setIsLoaderVisible(true);
timerRef.current = null;
}, PROGRESS_CONFIG.delay);
}
@@ -139,5 +143,11 @@ export default function AppProgressBar(): null {
};
}, [navigation.state]);
return null;
if (!isLoaderVisible) return null;
return (
<div className="pointer-events-none fixed inset-0 z-[9999] grid place-items-center bg-black/10 backdrop-blur-[1.5px]">
<NodedcProcessingLoader tone="white" variant="fluid" />
</div>
);
}
@@ -0,0 +1 @@
export { LiveAudioVisualizer } from "./live-audio-visualizer";
@@ -0,0 +1,172 @@
/**
* Localized audio visualizer based on the public API shape of `react-audio-visualize`.
* It stays in-repo so the Voice Tasker recording UI can keep the package behavior
* while preserving NODEDC styling and silent-state dots.
*/
import { useCallback, useEffect, useRef } from "react";
type LiveAudioVisualizerProps = {
backgroundColor?: string;
barColor?: string;
barWidth?: number;
fftSize?: 32 | 64 | 128 | 256 | 512 | 1024 | 2048 | 4096 | 8192 | 16384 | 32768;
gap?: number;
height?: number;
maxDecibels?: number;
mediaRecorder: MediaRecorder;
minDecibels?: number;
smoothingTimeConstant?: number;
width?: number;
};
const averageVoiceFrequencyBins = (data: Uint8Array, width: number, barWidth: number, gap: number) => {
let barsCount = Math.max(1, Math.floor(width / (barWidth + gap)));
const voiceBandBinCount = Math.max(barsCount, Math.floor(data.length * 0.32));
const voiceBand = data.slice(1, voiceBandBinCount);
let binWindow = Math.floor(voiceBand.length / barsCount);
if (barsCount > voiceBand.length) {
barsCount = voiceBand.length;
binWindow = 1;
}
return Array.from({ length: barsCount }, (_, index) => {
let peak = 0;
let sumSquares = 0;
let count = 0;
for (let offset = 0; offset < binWindow && index * binWindow + offset < voiceBand.length; offset++) {
const value = voiceBand[index * binWindow + offset] ?? 0;
peak = Math.max(peak, value);
sumSquares += value * value;
count++;
}
const rms = Math.sqrt(sumSquares / Math.max(1, count));
return Math.max(rms, peak * 0.72);
});
};
export function LiveAudioVisualizer(props: LiveAudioVisualizerProps) {
const {
backgroundColor = "transparent",
barColor = "rgb(160, 198, 255)",
barWidth = 2,
fftSize = 1024,
gap = 1,
height = 100,
maxDecibels = -10,
mediaRecorder,
minDecibels = -90,
smoothingTimeConstant = 0.4,
width = 300,
} = props;
const animationFrameRef = useRef<number | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const latestHeightsRef = useRef<number[]>([]);
const draw = useCallback(
(rawValues: number[]) => {
const canvas = canvasRef.current;
const context = canvas?.getContext("2d");
if (!canvas || !context) return;
const pixelRatio = window.devicePixelRatio || 1;
const canvasWidth = Math.max(1, Math.floor(width));
const canvasHeight = Math.max(1, Math.floor(height));
if (canvas.width !== canvasWidth * pixelRatio || canvas.height !== canvasHeight * pixelRatio) {
canvas.width = canvasWidth * pixelRatio;
canvas.height = canvasHeight * pixelRatio;
}
canvas.style.width = `${canvasWidth}px`;
canvas.style.height = `${canvasHeight}px`;
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
context.clearRect(0, 0, canvasWidth, canvasHeight);
if (backgroundColor !== "transparent") {
context.fillStyle = backgroundColor;
context.fillRect(0, 0, canvasWidth, canvasHeight);
}
const previousHeights = latestHeightsRef.current;
const centerY = canvasHeight / 2;
const maxBarHeight = canvasHeight * 0.94;
const nextHeights = rawValues.map((value, index) => {
const normalized = Math.max(0, Math.min(1, (value - 7) / 118));
const shapedValue = Math.min(1, Math.pow(normalized, 0.54) * 1.18);
const targetHeight = barWidth + shapedValue * (maxBarHeight - barWidth);
const previousHeight = previousHeights[index] ?? barWidth;
const smoothing = targetHeight > previousHeight ? 0.88 : 0.46;
return previousHeight + (targetHeight - previousHeight) * smoothing;
});
latestHeightsRef.current = nextHeights;
context.fillStyle = barColor;
nextHeights.forEach((barHeight, index) => {
const x = index * (barWidth + gap);
const y = centerY - barHeight / 2;
const radius = barWidth / 2;
context.beginPath();
if (context.roundRect) context.roundRect(x, y, barWidth, barHeight, radius);
else context.rect(x, y, barWidth, barHeight);
context.fill();
});
},
[backgroundColor, barColor, barWidth, gap, height, width]
);
useEffect(() => {
const stream = mediaRecorder.stream;
if (!stream) return;
const AudioContextClass =
window.AudioContext ||
(window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioContextClass) return;
const audioContext = new AudioContextClass();
const analyser = audioContext.createAnalyser();
const source = audioContext.createMediaStreamSource(stream);
analyser.fftSize = fftSize;
analyser.minDecibels = minDecibels;
analyser.maxDecibels = maxDecibels;
analyser.smoothingTimeConstant = smoothingTimeConstant;
const frequencyData = new Uint8Array(analyser.frequencyBinCount);
source.connect(analyser);
void audioContext.resume();
const renderFrame = () => {
if (mediaRecorder.state === "recording") {
analyser.getByteFrequencyData(frequencyData);
draw(averageVoiceFrequencyBins(frequencyData, width, barWidth, gap));
animationFrameRef.current = window.requestAnimationFrame(renderFrame);
return;
}
draw(averageVoiceFrequencyBins(new Uint8Array(analyser.frequencyBinCount), width, barWidth, gap));
};
renderFrame();
return () => {
if (animationFrameRef.current) {
window.cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
source.disconnect();
analyser.disconnect();
if (audioContext.state !== "closed") void audioContext.close();
};
}, [barWidth, draw, fftSize, gap, maxDecibels, mediaRecorder, minDecibels, smoothingTimeConstant, width]);
return <canvas ref={canvasRef} aria-hidden="true" />;
}