diff --git a/src/components/deepfake/VoiceCards.css b/src/components/deepfake/VoiceCards.css new file mode 100644 index 0000000..79f5747 --- /dev/null +++ b/src/components/deepfake/VoiceCards.css @@ -0,0 +1,143 @@ +.voice-cards { + display: flex; + flex-direction: column; + gap: 24px; + margin-top: 8px; +} + +/* ── Карточка-сценарий ── */ +.voice-card { + background: #1e1e2e; + border-radius: 16px; + padding: 16px 18px; + display: flex; + flex-direction: column; + gap: 10px; + box-shadow: 0 4px 20px rgba(0,0,0,.35); +} + +.voice-card-scenario { + font-size: 11px; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; + color: #a78bfa; +} + +.voice-card-quote { + font-size: 13px; + color: #c4c4d4; + line-height: 1.5; + border-left: 3px solid #7c3aed; + padding-left: 10px; + margin: 0; +} + +/* ── Аудиосообщение (Telegram-стиль) ── */ +.audio-msg { + display: flex; + align-items: center; + gap: 10px; + background: #2a2a3e; + border-radius: 22px; + padding: 10px 14px; +} + +/* Кнопка play/pause */ +.audio-msg-btn { + width: 40px; + height: 40px; + min-width: 40px; + border-radius: 50%; + border: none; + background: #7c3aed; + color: #fff; + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background .15s; + position: relative; +} +.audio-msg-btn:hover { background: #6d28d9; } +.audio-msg-btn.loading { + background: #3f3f5a; + cursor: default; + animation: pulse 1.2s ease-in-out infinite; +} +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: .5; } +} + +/* Спиннер внутри кнопки */ +.audio-spinner { + width: 18px; + height: 18px; + border: 2px solid rgba(255,255,255,.3); + border-top-color: #fff; + border-radius: 50%; + animation: spin .7s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* Волна + прогресс */ +.audio-msg-wave { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +} + +.waveform { + display: flex; + align-items: center; + gap: 2px; + height: 28px; + cursor: pointer; + position: relative; +} + +.waveform-bar { + flex: 1; + border-radius: 2px; + background: #4b4b6e; + transition: background .1s; +} +.waveform-bar.played { + background: #7c3aed; +} + +.audio-msg-time { + font-size: 11px; + color: #6b6b8a; + font-variant-numeric: tabular-nums; +} + +/* Кнопка перегенерации */ +.regen-btn { + background: none; + border: 1px solid #3f3f5a; + color: #a0a0c0; + border-radius: 20px; + padding: 5px 12px; + font-size: 12px; + cursor: pointer; + align-self: flex-end; + transition: border-color .15s, color .15s; +} +.regen-btn:hover:not(:disabled) { + border-color: #7c3aed; + color: #a78bfa; +} +.regen-btn:disabled { opacity: .4; cursor: default; } + +/* Ошибка */ +.voice-card-error { + font-size: 12px; + color: #f87171; + display: flex; + align-items: center; + gap: 6px; +} diff --git a/src/components/deepfake/VoiceCards.tsx b/src/components/deepfake/VoiceCards.tsx new file mode 100644 index 0000000..bdcfc08 --- /dev/null +++ b/src/components/deepfake/VoiceCards.tsx @@ -0,0 +1,184 @@ +import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; +import './VoiceCards.css'; + +const API_URL = 'https://back.hack.kinsle.ru/process-audio'; + +// Примеры — какой текст синтезируется вашим голосом +const EXAMPLES = [ + { + scenario: 'Начнем с начала', + text: 'Здравствуй! Сейчас ты увидишь, что мошейнники могут сделать, получив твой голос.', + exaggeration: "0.6", + cfg_weight: "0.5", + temperature: "0.4", + }, + { + scenario: '📞 Мошенник — родственнику', + text: 'Мам, я попал в аварию. Мне срочно нужны деньги, пожалуйста переведи на карту, я потом всё объясню.', + exaggeration: "0.6", + cfg_weight: "0.65", + temperature: "0.35", + }, + { + scenario: '🏦 Фейковый звонок из банка', + text: 'Здравствуйте, я сотрудник службы безопасности банка. По вашей карте зафиксирована подозрительная операция. Назовите код из смс.', + }, + { + scenario: '💼 Давление на коллегу', + text: 'Это директор. Переведи сто тысяч на реквизиты, которые я пришлю в чат. Никому не говори — это конфиденциально.', + }, +]; + +// Генерирует случайные высоты баров волны (стабильны для одного экземпляра) +function makeWaveBars(count = 40) { + return Array.from({ length: count }, () => 20 + Math.random() * 80); +} + +const fmt = (s: number) => + `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`; + +/* ────────────────────────── VoiceCard ────────────────────────── */ + +interface VoiceCardProps { + scenario: string; + text: string; + voiceBlob: Blob; + + exaggeration: string; + temperature: string; + cfg_weight: string; +} + +const VoiceCard = ({ scenario, text, voiceBlob, exaggeration, temperature, cfg_weight }: VoiceCardProps) => { + type Status = 'loading' | 'ready' | 'error'; + + const [status, setStatus] = useState('loading'); + const [audioUrl, setAudioUrl] = useState(''); + const [isPlaying, setIsPlaying] = useState(false); + const [elapsed, setElapsed] = useState(0); + const [total, setTotal] = useState(0); + + const audioRef = useRef(null); + const bars = useMemo(() => makeWaveBars(), []); + const playedCount = total > 0 ? Math.round((elapsed / total) * bars.length) : 0; + + const generate = useCallback(async () => { + setStatus('loading'); + setAudioUrl(''); + setElapsed(0); + setTotal(0); + setIsPlaying(false); + + try { + const fd = new FormData(); + fd.append('audio_file', voiceBlob, 'voice_recording.wav'); + fd.append('text', text); + fd.append('language_id', 'ru'); + fd.append('exaggeration', exaggeration ?? '0.5'); + fd.append('temperature', temperature ?? '0.8'); + fd.append('seed_num', String(Math.floor(Math.random() * 10000))); + fd.append('cfg_weight', cfg_weight ?? '0.5'); + + const res = await fetch(API_URL, { method: 'POST', body: fd }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + const blob = await res.blob(); + setAudioUrl(URL.createObjectURL(blob)); + setStatus('ready'); + } catch { + setStatus('error'); + } + }, [voiceBlob, text]); + + useEffect(() => { generate(); }, [generate]); + + const togglePlay = () => { + const a = audioRef.current; + if (!a) return; + isPlaying ? a.pause() : a.play(); + }; + + const seekTo = (e: React.MouseEvent) => { + const a = audioRef.current; + if (!a || !total) return; + const rect = e.currentTarget.getBoundingClientRect(); + a.currentTime = ((e.clientX - rect.left) / rect.width) * total; + }; + + return ( +
+
{scenario}
+

"{text}"

+ +
+ {/* Play / pause / loading */} + + + {/* Волна + время */} +
+
+ {bars.map((h, i) => ( +
+ ))} +
+
+ {status === 'loading' && 'генерация...'} + {status === 'error' && 'ошибка'} + {status === 'ready' && `${fmt(elapsed)} / ${fmt(total)}`} +
+
+
+ + {status === 'ready' && ( +
+ ); +}; + +/* ────────────────────────── VoiceCards ────────────────────────── */ + +interface VoiceCardsProps { + voiceBlob: Blob; +} + +const VoiceCards = ({ voiceBlob }: VoiceCardsProps) => ( +
+ {EXAMPLES.map((ex) => ( + // @ts-ignore + + ))} +
+); + +export default VoiceCards;