UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: фильтры активности и единый voice loader
This commit is contained in:
@@ -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";
|
||||
+172
@@ -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" />;
|
||||
}
|
||||
Reference in New Issue
Block a user