1
0
Fork 0

added voice card

This commit is contained in:
koplenov 2026-04-05 02:29:14 +03:00
parent 5bf668e029
commit df54ac9c76
2 changed files with 327 additions and 0 deletions

View 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;
}

View 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;