added voice card
This commit is contained in:
parent
5bf668e029
commit
df54ac9c76
2 changed files with 327 additions and 0 deletions
143
src/components/deepfake/VoiceCards.css
Normal file
143
src/components/deepfake/VoiceCards.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
184
src/components/deepfake/VoiceCards.tsx
Normal file
184
src/components/deepfake/VoiceCards.tsx
Normal file
|
|
@ -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<Status>('loading');
|
||||||
|
const [audioUrl, setAudioUrl] = useState('');
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [elapsed, setElapsed] = useState(0);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(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<HTMLDivElement>) => {
|
||||||
|
const a = audioRef.current;
|
||||||
|
if (!a || !total) return;
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
a.currentTime = ((e.clientX - rect.left) / rect.width) * total;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="voice-card">
|
||||||
|
<div className="voice-card-scenario">{scenario}</div>
|
||||||
|
<p className="voice-card-quote">"{text}"</p>
|
||||||
|
|
||||||
|
<div className="audio-msg">
|
||||||
|
{/* Play / pause / loading */}
|
||||||
|
<button
|
||||||
|
className={`audio-msg-btn${status === 'loading' ? ' loading' : ''}`}
|
||||||
|
onClick={status === 'ready' ? togglePlay : undefined}
|
||||||
|
disabled={status !== 'ready'}
|
||||||
|
title={isPlaying ? 'Пауза' : 'Воспроизвести'}
|
||||||
|
>
|
||||||
|
{status === 'loading' && <span className="audio-spinner" />}
|
||||||
|
{status === 'error' && '⚠'}
|
||||||
|
{status === 'ready' && (isPlaying ? '⏸' : '▶')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Волна + время */}
|
||||||
|
<div className="audio-msg-wave">
|
||||||
|
<div className="waveform" onClick={seekTo}>
|
||||||
|
{bars.map((h, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`waveform-bar${i < playedCount ? ' played' : ''}`}
|
||||||
|
style={{ height: `${h}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="audio-msg-time">
|
||||||
|
{status === 'loading' && 'генерация...'}
|
||||||
|
{status === 'error' && 'ошибка'}
|
||||||
|
{status === 'ready' && `${fmt(elapsed)} / ${fmt(total)}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === 'ready' && (
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={audioUrl}
|
||||||
|
onPlay={() => setIsPlaying(true)}
|
||||||
|
onPause={() => setIsPlaying(false)}
|
||||||
|
onEnded={() => { setIsPlaying(false); setElapsed(0); }}
|
||||||
|
onTimeUpdate={() => setElapsed(audioRef.current?.currentTime ?? 0)}
|
||||||
|
onLoadedMetadata={() => setTotal(audioRef.current?.duration ?? 0)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="regen-btn"
|
||||||
|
onClick={generate}
|
||||||
|
disabled={status === 'loading'}
|
||||||
|
title="Перегенерировать"
|
||||||
|
>
|
||||||
|
🔄 Перегенерировать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ────────────────────────── VoiceCards ────────────────────────── */
|
||||||
|
|
||||||
|
interface VoiceCardsProps {
|
||||||
|
voiceBlob: Blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VoiceCards = ({ voiceBlob }: VoiceCardsProps) => (
|
||||||
|
<div className="voice-cards">
|
||||||
|
{EXAMPLES.map((ex) => (
|
||||||
|
// @ts-ignore
|
||||||
|
<VoiceCard key={ex.scenario} voiceBlob={voiceBlob} {...ex} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default VoiceCards;
|
||||||
Loading…
Add table
Reference in a new issue