1
0
Fork 0

replace voice recorder realization for support chrome

This commit is contained in:
koplenov 2026-04-05 02:31:48 +03:00
parent 729a3c18ac
commit 66716d2314

View file

@ -1,356 +1,388 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { Recorder } from '../../lib/recorder'; import { Recorder } from '../../lib/recorder';
import VoiceCards from './VoiceCards';
import './ChatterboxTTS.css'; import './ChatterboxTTS.css';
const API_URL = 'https://back.hack.kinsle.ru/process-audio'; const API_URL = 'https://back.hack.kinsle.ru/process-audio';
const ChatterboxTTS = () => { const ChatterboxTTS = () => {
// Recording state // Recording state
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
const [hasRecording, setHasRecording] = useState(false); const [hasRecording, setHasRecording] = useState(false);
const [isUsingRecordedVoice, setIsUsingRecordedVoice] = useState(false); const [isUsingRecordedVoice, setIsUsingRecordedVoice] = useState(false);
const [recordStatus, setRecordStatus] = useState('Нажмите для записи'); const [recordStatus, setRecordStatus] = useState('Нажмите для записи');
const [recordTime, setRecordTime] = useState(''); const [recordTime, setRecordTime] = useState('');
const [showVoiceText, setShowVoiceText] = useState(false); const [showVoiceText, setShowVoiceText] = useState(false);
const [showVoicePreview, setShowVoicePreview] = useState(false); const [showVoicePreview, setShowVoicePreview] = useState(false);
const [recordedAudioUrl, setRecordedAudioUrl] = useState(''); const [recordedAudioUrl, setRecordedAudioUrl] = useState('');
// File state // File state
const [fileName, setFileName] = useState(''); const [fileName, setFileName] = useState('');
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
// Form state // Form state
const [text, setText] = useState('В прошлом месяце мы достигли нового рубежа.'); const [text, setText] = useState('В прошлом месяце мы достигли нового рубежа.');
const [language, setLanguage] = useState('ru'); const [language, setLanguage] = useState('ru');
const [exaggeration, setExaggeration] = useState(0.5); const [exaggeration, setExaggeration] = useState(0.5);
const [temperature, setTemperature] = useState(0.8); const [temperature, setTemperature] = useState(0.8);
const [cfgWeight, setCfgWeight] = useState(0.5); const [cfgWeight, setCfgWeight] = useState(0.5);
const [seed, setSeed] = useState(0); const [seed, setSeed] = useState(0);
// UI state // UI state
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [resultUrl, setResultUrl] = useState(''); const [resultUrl, setResultUrl] = useState('');
// Refs (не тригерят ре-рендер) // Refs (не тригерят ре-рендер)
const recorderRef = useRef<Recorder | null>(null); const recorderRef = useRef<Recorder | null>(null);
const audioContextRef = useRef<AudioContext | null>(null); const audioContextRef = useRef<AudioContext | null>(null);
const streamRef = useRef<MediaStream | null>(null); const streamRef = useRef<MediaStream | null>(null);
const recordedBlobRef = useRef<Blob | null>(null); const recordedBlobRef = useRef<Blob | null>(null);
const recordingStartRef = useRef<number>(0); const recordingStartRef = useRef<number>(0);
const timerIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const timerIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const fileRef = useRef<File | null>(null); const fileRef = useRef<File | null>(null);
// Запрос микрофона при монтировании // Запрос микрофона при монтировании
useEffect(() => { useEffect(() => {
navigator.mediaDevices.getUserMedia({ audio: true }) navigator.mediaDevices.getUserMedia({ audio: true })
.then(stream => stream.getTracks().forEach(t => t.stop())) .then(stream => stream.getTracks().forEach(t => t.stop()))
.catch(() => {}); .catch(() => { });
}, []); }, []);
const updateTimer = () => { const updateTimer = () => {
const elapsed = Math.floor((Date.now() - recordingStartRef.current) / 1000); const elapsed = Math.floor((Date.now() - recordingStartRef.current) / 1000);
const mins = Math.floor(elapsed / 60).toString().padStart(2, '0'); const mins = Math.floor(elapsed / 60).toString().padStart(2, '0');
const secs = (elapsed % 60).toString().padStart(2, '0'); const secs = (elapsed % 60).toString().padStart(2, '0');
setRecordTime(`${mins}:${secs}`); setRecordTime(`${mins}:${secs}`);
}; };
const startRecording = async () => { const startRecording = async () => {
try { try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream; streamRef.current = stream;
const audioContext = new AudioContext(); const audioContext = new AudioContext();
audioContextRef.current = audioContext; audioContextRef.current = audioContext;
const source = audioContext.createMediaStreamSource(stream); const source = audioContext.createMediaStreamSource(stream);
const recorder = await Recorder.create(source, { numChannels: 1 }); const recorder = await Recorder.create(source, { numChannels: 1 });
recorderRef.current = recorder; recorderRef.current = recorder;
setIsRecording(true); setIsRecording(true);
setHasRecording(false); setHasRecording(false);
setShowVoiceText(true); setShowVoiceText(true);
setShowVoicePreview(false); setShowVoicePreview(false);
setIsUsingRecordedVoice(false); setIsUsingRecordedVoice(false);
setRecordStatus('Идёт запись...'); setRecordStatus('Идёт запись...');
setRecordTime('00:00'); setRecordTime('00:00');
recordingStartRef.current = Date.now(); recordingStartRef.current = Date.now();
updateTimer(); updateTimer();
timerIntervalRef.current = setInterval(updateTimer, 1000); timerIntervalRef.current = setInterval(updateTimer, 1000);
} catch (err) { } catch (err) {
alert('Не удалось получить доступ к микрофону: ' + (err as Error).message); alert('Не удалось получить доступ к микрофону: ' + (err as Error).message);
} }
}; };
const stopRecording = () => { const stopRecording = () => {
const recorder = recorderRef.current; const recorder = recorderRef.current;
if (!recorder) return; if (!recorder) return;
recorder.stop(); recorder.stop();
// Сразу обновляем UI — не ждём окончания экспорта // Сразу обновляем UI — не ждём окончания экспорта
setIsRecording(false); setIsRecording(false);
setRecordStatus('Обработка...'); setRecordStatus('Обработка...');
if (timerIntervalRef.current) { if (timerIntervalRef.current) {
clearInterval(timerIntervalRef.current); clearInterval(timerIntervalRef.current);
timerIntervalRef.current = null; timerIntervalRef.current = null;
} }
setRecordTime(''); setRecordTime('');
recorder.exportWAV((blob) => { recorder.exportWAV((blob) => {
recorder.clear(); recorder.clear();
recorder.destroy(); recorder.destroy();
recorderRef.current = null; recorderRef.current = null;
streamRef.current?.getTracks().forEach(t => t.stop()); streamRef.current?.getTracks().forEach(t => t.stop());
streamRef.current = null; streamRef.current = null;
audioContextRef.current?.close(); audioContextRef.current?.close();
audioContextRef.current = null; audioContextRef.current = null;
recordedBlobRef.current = blob; recordedBlobRef.current = blob;
setRecordedAudioUrl(URL.createObjectURL(blob)); setRecordedAudioUrl(URL.createObjectURL(blob));
setShowVoicePreview(true); setShowVoicePreview(true);
setShowVoiceText(false); setShowVoiceText(false);
setRecordStatus('Запись завершена'); setRecordStatus('Запись завершена');
setHasRecording(true); setHasRecording(true);
}); });
}; };
const handleRecordClick = () => { const handleRecordClick = () => {
if (isRecording) stopRecording(); if (isRecording) stopRecording();
else startRecording(); else startRecording();
}; };
const handleRetry = () => { const handleRetry = () => {
setShowVoicePreview(false); setShowVoicePreview(false);
setHasRecording(false); setHasRecording(false);
setIsUsingRecordedVoice(false); setIsUsingRecordedVoice(false);
setRecordStatus('Нажмите для записи'); setRecordStatus('Нажмите для записи');
recordedBlobRef.current = null; recordedBlobRef.current = null;
}; };
const handleUseRecording = () => { const handleUseRecording = () => {
setIsUsingRecordedVoice(true); setIsUsingRecordedVoice(true);
setRecordStatus('✅ Голосовое сообщение сохранено'); setRecordStatus('✅ Голосовое сообщение сохранено');
// Сбрасываем файл // Сбрасываем файл
fileRef.current = null; fileRef.current = null;
setFileName(''); setFileName('');
if (fileInputRef.current) fileInputRef.current.value = ''; if (fileInputRef.current) fileInputRef.current.value = '';
}; };
const updateFileName = (file: File) => { const updateFileName = (file: File) => {
fileRef.current = file; fileRef.current = file;
setFileName(file.name); setFileName(file.name);
setIsUsingRecordedVoice(false); setIsUsingRecordedVoice(false);
}; };
const handleDrop = (e: React.DragEvent) => { const handleDrop = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
setIsDragOver(false); setIsDragOver(false);
const file = e.dataTransfer.files[0]; const file = e.dataTransfer.files[0];
if (file) updateFileName(file); if (file) updateFileName(file);
}; };
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) updateFileName(file); if (file) updateFileName(file);
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setResultUrl(''); setResultUrl('');
setError(''); setError('');
setLoading(true); setLoading(true);
try { try {
const formData = new FormData(); const formData = new FormData();
if (isUsingRecordedVoice) { if (isUsingRecordedVoice) {
if (!recordedBlobRef.current) { if (!recordedBlobRef.current) {
throw new Error('Запись голоса потеряна — запишите снова'); throw new Error('Запись голоса потеряна — запишите снова');
} }
formData.append('audio_file', recordedBlobRef.current, 'voice_recording.wav'); formData.append('audio_file', recordedBlobRef.current, 'voice_recording.wav');
} else if (fileRef.current) { } else if (fileRef.current) {
formData.append('audio_file', fileRef.current); formData.append('audio_file', fileRef.current);
} else { } else {
throw new Error('Выберите аудио файл или запишите голос'); throw new Error('Выберите аудио файл или запишите голос');
} }
formData.append('text', text); formData.append('text', text);
formData.append('language_id', language); formData.append('language_id', language);
formData.append('exaggeration', String(exaggeration)); formData.append('exaggeration', String(exaggeration));
formData.append('temperature', String(temperature)); formData.append('temperature', String(temperature));
formData.append('seed_num', String(seed)); formData.append('seed_num', String(seed));
formData.append('cfg_weight', String(cfgWeight)); formData.append('cfg_weight', String(cfgWeight));
console.log('[ChatterboxTTS] отправка:', { console.log('[ChatterboxTTS] отправка:', {
audio: isUsingRecordedVoice ? 'recorded blob' : fileRef.current?.name, audio: isUsingRecordedVoice ? 'recorded blob' : fileRef.current?.name,
text, language, exaggeration, temperature, cfgWeight, seed, text, language, exaggeration, temperature, cfgWeight, seed,
}); });
const response = await fetch(API_URL, { method: 'POST', body: formData }); const response = await fetch(API_URL, { method: 'POST', body: formData });
const contentType = response.headers.get('Content-Type') ?? ''; const contentType = response.headers.get('Content-Type') ?? '';
console.log('[ChatterboxTTS] ответ:', response.status, contentType); console.log('[ChatterboxTTS] ответ:', response.status, contentType);
if (!response.ok || contentType.includes('application/json')) { if (!response.ok || contentType.includes('application/json')) {
const errData = await response.json().catch(() => ({})); const errData = await response.json().catch(() => ({}));
const msg = (errData as { error?: string }).error; const msg = (errData as { error?: string }).error;
if (msg) throw new Error(msg); if (msg) throw new Error(msg);
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
throw new Error('Сервер вернул JSON вместо аудио — возможно audio_file не принят'); throw new Error('Сервер вернул JSON вместо аудио — возможно audio_file не принят');
} }
const blob = await response.blob(); const blob = await response.blob();
setResultUrl(URL.createObjectURL(blob)); setResultUrl(URL.createObjectURL(blob));
} catch (err) { } catch (err) {
setError('❌ Ошибка: ' + (err as Error).message); setError('❌ Ошибка: ' + (err as Error).message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const voiceSectionClass = [ const voiceSectionClass = [
'voice-section', 'voice-section',
isRecording ? 'recording' : '', isRecording ? 'recording' : '',
hasRecording && !isRecording ? 'has-recording' : '', hasRecording && !isRecording ? 'has-recording' : '',
].filter(Boolean).join(' '); ].filter(Boolean).join(' ');
return ( return (
<div className="chatterbox-container"> <div className="chatterbox-container">
<h1>🎙 Chatterbox TTS</h1> <h1>Подделка голоса</h1>
<form onSubmit={handleSubmit}> <p>
{/* Запись голоса */} Deepfake синтез правдоподобных поддельных изображений, видео и звука при помощи искусственного интеллекта. Чаще всего дипфейки изображают известных людей в вымышленных ситуациях.
<div className={voiceSectionClass}> </p>
<div className="voice-label">
🎤 Запишите голосовое сообщение для проверки на бота
{isUsingRecordedVoice && <span className="check-badge"> Готово</span>}
</div>
<button type="button" className={`record-btn${isRecording ? ' recording' : ''}`} onClick={handleRecordClick}> <p>
{isRecording ? '⏹' : '⏺'} Давайте на нашем примере разберемся с этим! Для этого нам нужен небольшой отрывок вашего голоса. Не переживайте - это безопасно! Мы трепетно относимся к чувствительной информации, не храним ваши данные после обработки и не передаем их третьим лицам - все строго в соответствии с политикой соглашения.
</button> </p>
<div className="record-status">{recordStatus}</div> <form onSubmit={handleSubmit}>
{recordTime && <div className="record-timer">{recordTime}</div>} {/* Запись голоса */}
<div className={voiceSectionClass}>
<div className="voice-label">
🎤 Запишите голосовое сообщение - достаточно 10-15 секунд вашего голоса
{isUsingRecordedVoice && <span className="check-badge"> Готово</span>}
</div>
{showVoiceText && ( <button type="button" className={`record-btn${isRecording ? ' recording' : ''}`} onClick={handleRecordClick}>
<div className="voice-text"> {isRecording ? '⏹' : '⏺'}
📢 Произнесите: <span style={{ color: '#667eea' }}>"Хакатон 2026 французский стиль"</span> </button>
</div>
)}
{showVoicePreview && ( <div className="record-status">{recordStatus}</div>
<div className="voice-preview"> {recordTime && <div className="record-timer">{recordTime}</div>}
<audio src={recordedAudioUrl} controls />
<div className="voice-actions">
<button type="button" className="btn-retry" onClick={handleRetry}>🔄 Перезаписать</button>
<button type="button" className="btn-use" onClick={handleUseRecording}> Использовать</button>
</div>
</div>
)}
</div>
{/* Загрузка файла */} {showVoiceText && (
<div className="or-divider">или загрузите файл</div> <div className="voice-text">
📢 Произнесите, например: <span style={{ color: '#667eea' }}>"Хакатон 2026 походим модуль deepfake, кейс Цент Инвест команда Атейкин"</span>
</div>
)}
<div className="form-group"> {showVoicePreview && (
<div <div className="voice-preview">
className={`file-input-wrapper${fileName ? ' has-file' : ''}`} <audio src={recordedAudioUrl} controls />
onClick={() => fileInputRef.current?.click()} <div className="voice-actions">
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }} <button type="button" className="btn-retry" onClick={handleRetry}>🔄 Перезаписать</button>
onDragLeave={() => setIsDragOver(false)} <button type="button" className="btn-use" onClick={handleUseRecording}> Использовать</button>
onDrop={handleDrop} </div>
style={isDragOver ? { borderColor: '#667eea' } : undefined} </div>
> )}
<input ref={fileInputRef} type="file" accept="audio/*" onChange={handleFileChange} style={{ display: 'none' }} /> </div>
<div>{fileName ? '✅ Файл выбран' : '📁 Кликни или перетащи аудио файл'}</div>
{fileName && <div className="file-name">{fileName}</div>}
</div>
</div>
{/* Текст */} {/* Загрузка файла */}
<div className="form-group"> <div className="or-divider">или загрузите файл фрагмента голоса, желательно свой :D</div>
<label>Текст для синтеза (макс 300 символов)</label>
<textarea maxLength={300} value={text} onChange={e => setText(e.target.value)} placeholder="Введите текст..." />
<div className="char-counter"><span>{text.length}</span>/300</div>
</div>
{/* Язык */} <div className="form-group">
<div className="form-group"> <div
<label>Язык</label> className={`file-input-wrapper${fileName ? ' has-file' : ''}`}
<select value={language} onChange={e => setLanguage(e.target.value)}> onClick={() => fileInputRef.current?.click()}
<option value="en">English</option> onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
<option value="ru">Russian</option> onDragLeave={() => setIsDragOver(false)}
<option value="de">German</option> onDrop={handleDrop}
<option value="es">Spanish</option> style={isDragOver ? { borderColor: '#667eea' } : undefined}
<option value="fr">French</option> >
<option value="it">Italian</option> <input ref={fileInputRef} type="file" accept="audio/*" onChange={handleFileChange} style={{ display: 'none' }} />
<option value="pt">Portuguese</option> <div>{fileName ? '✅ Файл выбран' : '📁 Кликни или перетащи аудио файл'}</div>
<option value="hi">Hindi</option> {fileName && <div className="file-name">{fileName}</div>}
</select> </div>
</div> </div>
{/* Слайдеры */} <p>
<div className="form-group"> Дипфейки часто создают и применяют в преступных целях. Их могут использовать для распространения ложных новостей, обмана или прохождения аудио- и видео- аутентификации.
<label>Exaggeration (экспрессивность)</label> </p>
<div className="slider-group">
<input type="range" min="0.25" max="2" step="0.05" value={exaggeration} onChange={e => setExaggeration(Number(e.target.value))} />
<span className="slider-value">{exaggeration}</span>
</div>
</div>
<div className="form-group"> <p>
<label>Temperature (разнообразие)</label> Имея небольшой отрывок речи человека, мошенники могут полностью возсоздать интонацию, произношение и темперамет голоса.
<div className="slider-group"> </p>
<input type="range" min="0.05" max="5" step="0.05" value={temperature} onChange={e => setTemperature(Number(e.target.value))} />
<span className="slider-value">{temperature}</span>
</div>
</div>
<div className="form-group">
<label>CFG Weight</label>
<div className="slider-group">
<input type="range" min="0.2" max="1" step="0.05" value={cfgWeight} onChange={e => setCfgWeight(Number(e.target.value))} />
<span className="slider-value">{cfgWeight}</span>
</div>
</div>
<div className="two-col"> {((isUsingRecordedVoice && recordedBlobRef.current) || fileName) && (
<div className="form-group"> <div>
<label>Random Seed (0 = random)</label> <p>
<input type="number" value={seed} min={0} onChange={e => setSeed(Number(e.target.value))} /> Вот примеры дипфейков.
</div> </p>
</div>
<button type="submit" className="submit-btn" disabled={loading}> <VoiceCards voiceBlob={recordedBlobRef.current} />
🚀 Сгенерировать аудио
</button>
</form>
{loading && ( <h2>
<div className="loading-block"> Вы можете попробовать самостоятельно сделат дипфейк из своего голоса!
<div className="spinner" /> </h2>
<p>Генерируем аудио... Это может занять 10-30 секунд</p>
</div>
)}
{error && <div className="error-block">{error}</div>} {/* Текст */}
<div className="form-group">
<label>Текст для синтеза (макс 300 символов)</label>
<textarea maxLength={300} value={text} onChange={e => setText(e.target.value)} placeholder="Введите текст..." />
<div className="char-counter"><span>{text.length}</span>/300</div>
</div>
{resultUrl && ( {/* Язык */}
<div className="result-block"> {/* <div className="form-group">
<h3> Готово!</h3> <label>Язык</label>
<audio src={resultUrl} controls /> <select value={language} onChange={e => setLanguage(e.target.value)}>
<br /> <option value="en">English</option>
<a href={resultUrl} className="download-btn" download="output.wav">💾 Скачать WAV</a> <option value="ru">Russian</option>
</div> <option value="de">German</option>
)} <option value="es">Spanish</option>
</div> <option value="fr">French</option>
); <option value="it">Italian</option>
<option value="pt">Portuguese</option>
<option value="hi">Hindi</option>
</select>
</div> */}
{/* Слайдеры */}
<div className="form-group">
<label>Exaggeration (экспрессивность)</label>
<div className="slider-group">
<input type="range" min="0.25" max="2" step="0.05" value={exaggeration} onChange={e => setExaggeration(Number(e.target.value))} />
<span className="slider-value">{exaggeration}</span>
</div>
</div>
<div className="form-group">
<label>Temperature (разнообразие)</label>
<div className="slider-group">
<input type="range" min="0.05" max="5" step="0.05" value={temperature} onChange={e => setTemperature(Number(e.target.value))} />
<span className="slider-value">{temperature}</span>
</div>
</div>
<div className="form-group">
<label>CFG Weight</label>
<div className="slider-group">
<input type="range" min="0.2" max="1" step="0.05" value={cfgWeight} onChange={e => setCfgWeight(Number(e.target.value))} />
<span className="slider-value">{cfgWeight}</span>
</div>
</div>
<div className="two-col">
<div className="form-group">
<label>Random Seed (0 = random)</label>
<input type="number" value={seed} min={0} onChange={e => setSeed(Number(e.target.value))} />
</div>
</div>
<button type="submit" className="submit-btn" disabled={loading}>
🚀 Сгенерировать аудио
</button>
</div>
)}
</form>
{loading && (
<div className="loading-block">
<div className="spinner" />
<p>Генерируем аудио... Это может занять 5-15 секунд</p>
</div>
)}
{error && <div className="error-block">{error}</div>}
{resultUrl && (
<div className="result-block">
<h3> Готово!</h3>
<audio src={resultUrl} controls />
<br />
<a href={resultUrl} className="download-btn" download="output.wav">💾 Скачать WAV'ку!</a>
</div>
)}
</div>
);
}; };
export default ChatterboxTTS; export default ChatterboxTTS;