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