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