321 lines
11 KiB
TypeScript
321 lines
11 KiB
TypeScript
/* eslint-disable react-hooks/exhaustive-deps */
|
||
import { useState, useRef, useEffect } from "react";
|
||
import ModalWrapper from "../ModalWrapper";
|
||
import Button from "../ui/Button";
|
||
import Select from "../ui/Select";
|
||
import RestartIcon from "../icons/RestartIcon";
|
||
import useModalStore from "../../store/modalStore";
|
||
import SettingsModal from "./SettingsModal";
|
||
import clsx from "clsx";
|
||
import { isMediaDevicesSupported } from "../../lib/mediaDevices";
|
||
|
||
interface VoiceCheckModalProps {
|
||
selectedMicrophone: string;
|
||
microphones: { deviceId: string; label: string }[];
|
||
microphoneVolume: number;
|
||
onSelectMicrophone: (label: string) => void;
|
||
}
|
||
|
||
function VoiceCheckModal({
|
||
selectedMicrophone,
|
||
microphones,
|
||
microphoneVolume,
|
||
onSelectMicrophone,
|
||
}: VoiceCheckModalProps) {
|
||
const { setModal } = useModalStore();
|
||
|
||
const [audioLevel, setAudioLevel] = useState(0);
|
||
const [status, setStatus] = useState<"default" | "success" | "error">(
|
||
"default"
|
||
);
|
||
const [isTestRunning, setIsTestRunning] = useState(false);
|
||
const [soundDetected, setSoundDetected] = useState(false);
|
||
const [maxAudioLevel, setMaxAudioLevel] = useState(0);
|
||
|
||
const audioContextRef = useRef<AudioContext | null>(null);
|
||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||
const streamRef = useRef<MediaStream | null>(null);
|
||
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||
const gainNodeRef = useRef<GainNode | null>(null);
|
||
const animationFrameRef = useRef<number | null>(null);
|
||
const testTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const statusRef = useRef<"default" | "success" | "error">("default");
|
||
|
||
function detectAudioLevel() {
|
||
if (!analyserRef.current) return;
|
||
|
||
const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount);
|
||
analyserRef.current.getByteFrequencyData(dataArray);
|
||
|
||
const average = dataArray.reduce((a, b) => a + b) / dataArray.length;
|
||
setAudioLevel(average);
|
||
|
||
// Обновляем максимальный уровень звука
|
||
setMaxAudioLevel((prev) => Math.max(prev, average));
|
||
|
||
// Если звук обнаружен и статус ещё не success, устанавливаем success
|
||
if (average > 5) {
|
||
if (statusRef.current !== "success") {
|
||
statusRef.current = "success";
|
||
setStatus("success");
|
||
}
|
||
setSoundDetected(true);
|
||
}
|
||
|
||
animationFrameRef.current = requestAnimationFrame(detectAudioLevel);
|
||
}
|
||
|
||
async function startMicrophoneTest() {
|
||
// Проверяем доступность API
|
||
if (!isMediaDevicesSupported()) {
|
||
console.error(
|
||
"navigator.mediaDevices недоступен. Возможно, требуется HTTPS или поддержка браузера."
|
||
);
|
||
setStatus("error");
|
||
setIsTestRunning(false);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const selectedMic = microphones.find(
|
||
(mic) => mic.label === selectedMicrophone
|
||
);
|
||
|
||
const constraints: MediaStreamConstraints = {
|
||
audio: selectedMic
|
||
? { deviceId: { exact: selectedMic.deviceId } }
|
||
: true,
|
||
};
|
||
|
||
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||
streamRef.current = stream;
|
||
|
||
const audioContext = new AudioContext();
|
||
audioContextRef.current = audioContext;
|
||
|
||
const analyser = audioContext.createAnalyser();
|
||
analyser.fftSize = 256;
|
||
analyserRef.current = analyser;
|
||
|
||
const gainNode = audioContext.createGain();
|
||
gainNode.gain.value = microphoneVolume / 100;
|
||
gainNodeRef.current = gainNode;
|
||
|
||
const source = audioContext.createMediaStreamSource(stream);
|
||
sourceRef.current = source;
|
||
|
||
// Подключаем: source -> gainNode -> analyser -> destination
|
||
source.connect(gainNode);
|
||
gainNode.connect(analyser);
|
||
analyser.connect(audioContext.destination);
|
||
|
||
// Сбрасываем статус при новом тесте
|
||
statusRef.current = "default";
|
||
setStatus("default");
|
||
setSoundDetected(false);
|
||
setAudioLevel(0);
|
||
setMaxAudioLevel(0);
|
||
setIsTestRunning(true);
|
||
|
||
// Запускаем проверку звука
|
||
detectAudioLevel();
|
||
|
||
// Останавливаем проверку через 3 секунды и устанавливаем результат
|
||
testTimeoutRef.current = setTimeout(() => {
|
||
// Останавливаем анимацию
|
||
if (animationFrameRef.current) {
|
||
cancelAnimationFrame(animationFrameRef.current);
|
||
animationFrameRef.current = null;
|
||
}
|
||
|
||
// Устанавливаем финальный статус
|
||
if (statusRef.current === "default") {
|
||
statusRef.current = "error";
|
||
setStatus("error");
|
||
}
|
||
// Если статус уже success, он остаётся success
|
||
|
||
setIsTestRunning(false);
|
||
|
||
// Очищаем ресурсы после завершения теста
|
||
cleanupAudioResources();
|
||
}, 3000);
|
||
} catch (error) {
|
||
console.error("Ошибка доступа к микрофону:", error);
|
||
setStatus("error");
|
||
setIsTestRunning(false);
|
||
}
|
||
}
|
||
|
||
function cleanupAudioResources() {
|
||
// Отключаем source
|
||
if (sourceRef.current) {
|
||
try {
|
||
sourceRef.current.disconnect();
|
||
} catch {
|
||
// Ignore if already disconnected
|
||
}
|
||
sourceRef.current = null;
|
||
}
|
||
|
||
// Отключаем gainNode
|
||
if (gainNodeRef.current) {
|
||
try {
|
||
gainNodeRef.current.disconnect();
|
||
} catch {
|
||
// Ignore if already disconnected
|
||
}
|
||
gainNodeRef.current = null;
|
||
}
|
||
|
||
// Отключаем analyser
|
||
if (analyserRef.current) {
|
||
try {
|
||
analyserRef.current.disconnect();
|
||
} catch {
|
||
// Ignore if already disconnected
|
||
}
|
||
analyserRef.current = null;
|
||
}
|
||
|
||
// Останавливаем все треки медиа-потока
|
||
if (streamRef.current) {
|
||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||
streamRef.current = null;
|
||
}
|
||
|
||
// Закрываем AudioContext
|
||
if (audioContextRef.current) {
|
||
audioContextRef.current.close();
|
||
audioContextRef.current = null;
|
||
}
|
||
}
|
||
|
||
function stopMicrophoneTest() {
|
||
if (animationFrameRef.current) {
|
||
cancelAnimationFrame(animationFrameRef.current);
|
||
animationFrameRef.current = null;
|
||
}
|
||
if (testTimeoutRef.current) {
|
||
clearTimeout(testTimeoutRef.current);
|
||
testTimeoutRef.current = null;
|
||
}
|
||
|
||
cleanupAudioResources();
|
||
|
||
// НЕ сбрасываем audioLevel и maxAudioLevel - они сохраняются до нового теста
|
||
setSoundDetected(false);
|
||
setIsTestRunning(false);
|
||
}
|
||
|
||
function restartMicrophoneTest() {
|
||
stopMicrophoneTest();
|
||
// Небольшая задержка перед запуском нового теста
|
||
setTimeout(startMicrophoneTest, 100);
|
||
}
|
||
|
||
useEffect(() => {
|
||
startMicrophoneTest();
|
||
|
||
return stopMicrophoneTest;
|
||
}, [selectedMicrophone]);
|
||
|
||
// Обновляем громкость микрофона при изменении слайдера
|
||
useEffect(() => {
|
||
if (gainNodeRef.current) {
|
||
gainNodeRef.current.gain.value = microphoneVolume / 100;
|
||
}
|
||
}, [microphoneVolume]);
|
||
|
||
// Генерируем высоты для баров на основе уровня звука
|
||
function generateBarHeights() {
|
||
const baseHeights = [
|
||
3, 12, 16, 20, 44, 28, 34, 60, 8, 40, 18, 36, 24, 13, 10, 6,
|
||
];
|
||
// Используем maxAudioLevel для сохранения максимальной высоты
|
||
const levelToUse = isTestRunning ? audioLevel : maxAudioLevel;
|
||
const multiplier = Math.min(levelToUse / 5);
|
||
return baseHeights.map((h) => Math.max(3, Math.min(60, h * multiplier)));
|
||
}
|
||
|
||
const barHeights = generateBarHeights();
|
||
|
||
console.log(barHeights);
|
||
|
||
return (
|
||
<ModalWrapper className="2xl:max-w-[21.111vw] max-w-[304px]">
|
||
<div className="flex flex-col 2xl:gap-[1.667vw] gap-6">
|
||
{/* Выбор микрофона */}
|
||
<div className="flex flex-col items-center 2xl:gap-[0.278vw] gap-1">
|
||
<p className="caption-xs text-[#7D7D7D] font-medium">Микрофон</p>
|
||
<Select
|
||
options={microphones.map((m) => m.label)}
|
||
defaultOption={selectedMicrophone}
|
||
onSelect={onSelectMicrophone}
|
||
/>
|
||
</div>
|
||
|
||
{/* Визуализация уровня звука */}
|
||
<div className="flex flex-col items-center 2xl:gap-[0.556vw] gap-2">
|
||
<div className="flex items-center justify-center 2xl:gap-[0.208vw] gap-[3px] 2xl:h-[4.167vw] h-[60px]">
|
||
{barHeights.map((height, index) => (
|
||
<div
|
||
key={index}
|
||
className={clsx(
|
||
`2xl:rounded-[0.556vw] rounded-lg transition-all duration-150 2xl:w-[0.208vw] w-[3px]`,
|
||
soundDetected ? "bg-[#7B60F3]" : "bg-[#D6D6D6]"
|
||
)}
|
||
style={{ height: `${(height / 1440) * innerWidth}px` }}
|
||
/>
|
||
))}
|
||
</div>
|
||
{!isTestRunning && status === "success" && (
|
||
<p className="caption-s text-[#29AF61] font-medium">Звук есть!</p>
|
||
)}
|
||
{!isTestRunning && status === "error" && (
|
||
<p className="caption-s text-[#FF4517] font-medium">
|
||
Звук не обнаружен
|
||
</p>
|
||
)}
|
||
{isTestRunning && (
|
||
<p className="caption-s text-[#7D7D7D] font-medium">Проверка...</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex flex-col items-center 2xl:gap-[0.556vw] gap-2 text-center">
|
||
<h2 className="title-l font-medium">Говорите</h2>
|
||
<p className="text-s text-[#7D7D7D]">
|
||
Вы должны видеть движение на индикаторе
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex flex-col 2xl:gap-[0.556vw] gap-2">
|
||
<Button
|
||
variant="secondary"
|
||
size="large"
|
||
onClick={restartMicrophoneTest}
|
||
disabled={isTestRunning}
|
||
className="w-full"
|
||
>
|
||
<div className="2xl:size-[1.389vw] size-5">
|
||
<RestartIcon />
|
||
</div>
|
||
Повторить проверку
|
||
</Button>
|
||
|
||
<Button
|
||
variant="primary"
|
||
size="large"
|
||
onClick={() => setModal(<SettingsModal />)}
|
||
className="w-full"
|
||
>
|
||
Завершить
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</ModalWrapper>
|
||
);
|
||
}
|
||
|
||
export default VoiceCheckModal;
|