|
|
<!DOCTYPE html> |
|
|
<html lang="fa" dir="rtl"> |
|
|
<head> |
|
|
<meta charset="UTF-8" /> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
|
<title>هوش مصنوعی آلفا صدا | AI Sada</title> |
|
|
|
|
|
|
|
|
<script src="https://cdn.tailwindcss.com"></script> |
|
|
<script> |
|
|
tailwind.config = { |
|
|
theme: { |
|
|
extend: { |
|
|
colors: { |
|
|
primary: '#4A6CFA', |
|
|
secondary: '#0FD4A8', |
|
|
accent: '#38b2ac', |
|
|
dark: '#1A202C', |
|
|
}, |
|
|
fontFamily: { |
|
|
vazir: ['Vazirmatn', 'sans-serif'], |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
</script> |
|
|
|
|
|
|
|
|
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@100;300;400;500;600;700;800;900&display=swap" rel="stylesheet"> |
|
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"> |
|
|
|
|
|
|
|
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script> |
|
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script> |
|
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script> |
|
|
|
|
|
<style> |
|
|
body { |
|
|
font-family: 'Vazirmatn', sans-serif; |
|
|
background: #F8F9FC; |
|
|
color: #1A202C; |
|
|
overflow-x: hidden; |
|
|
} |
|
|
|
|
|
::-webkit-scrollbar { width: 8px; } |
|
|
::-webkit-scrollbar-track { background: #f1f1f1; } |
|
|
::-webkit-scrollbar-thumb { background: #c7c7c7; border-radius: 4px; } |
|
|
::-webkit-scrollbar-thumb:hover { background: #a8a8a8; } |
|
|
.no-scrollbar::-webkit-scrollbar { display: none; } |
|
|
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } |
|
|
|
|
|
|
|
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } |
|
|
@keyframes scaleIn { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } } |
|
|
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } |
|
|
@keyframes shimmer { 100% { transform: translateX(100%); } } |
|
|
|
|
|
.animate-fade-in { animation: fadeIn 0.5s ease-out forwards; } |
|
|
.animate-scale-in { animation: scaleIn 0.3s ease-out forwards; } |
|
|
|
|
|
|
|
|
.glass-nav { |
|
|
background: rgba(255, 255, 255, 0.85); |
|
|
backdrop-filter: blur(12px); |
|
|
-webkit-backdrop-filter: blur(12px); |
|
|
border: 1px solid rgba(255, 255, 255, 0.5); |
|
|
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.1); |
|
|
} |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div id="root"></div> |
|
|
|
|
|
<script type="text/babel" data-presets="env,react,typescript"> |
|
|
const { useState, useEffect, useRef } = React; |
|
|
|
|
|
|
|
|
const PROXY_URL = '/tts/proxy.php'; |
|
|
|
|
|
|
|
|
const TTS_SPEAKERS = [ |
|
|
{ id: "Charon", name: "شهاب (مرد)", img: "https://uploadkon.ir/uploads/a18705_25IMG-۲۰۲۵۰۷۰۵-۱۱۰۵۴۹.jpg", desc: "صدایی قدرتمند و رسا" }, |
|
|
{ id: "Zephyr", name: "آوا (زن)", img: "https://uploadkon.ir/uploads/029605_25IMG-۲۰۲۵۰۷۰۵-۱۱۱۲۵۲.jpg", desc: "لطیف و دلنشین" }, |
|
|
{ id: "Achird", name: "نوید (مرد)", img: "https://uploadkon.ir/uploads/697e05_25IMG-۲۰۲۵۰۶۰۹-۰۶۴۶۳۷.jpg", desc: "جوان و پرانرژی" }, |
|
|
{ id: "Zubenelgenubi", name: "آرمان (مرد)", img: "https://uploadkon.ir/uploads/a8a705_25IMG-۲۰۲۵۰۷۰۵-۱۱۱۶۲۹.jpg", desc: "گرم و صمیمی" }, |
|
|
{ id: "Vindemiatrix", name: "مهسا (زن)", img: "https://uploadkon.ir/uploads/d74d05_25IMG-۲۰۲۵۰۷۰۵-۱۱۱۸۳۸.jpg", desc: "باوقار و رسمی" }, |
|
|
{ id: "Algieba", name: "آرتین (مرد)", img: "https://uploadkon.ir/uploads/571005_25IMG-۲۰۲۵۰۷۰۵-۱۱۵۰۳۹.jpg", desc: "با اصالت و شیک" } |
|
|
]; |
|
|
|
|
|
const VC_MODELS = [ |
|
|
{ id: 'shadmehr', name: 'شادمهر عقیلی', img: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/1000188203.jpg?_t=1725334498', ref: '/refs/shadmehr.wav' }, |
|
|
{ id: 'moein', name: 'معین', img: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/5dbc55de-d6ab-442f-9a00-da874521cc0b.jpg?_t=1725334795', ref: '/refs/moein.wav' }, |
|
|
{ id: 'billie', name: 'بیلی آیلیش', img: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/1551c598-f02f-4ced-a037-33d2d7317edd.jpg?_t=1726723022', ref: '/refs/billie.wav' }, |
|
|
{ id: 'chavoshi', name: 'محسن چاوشی', img: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/c52eefb1-071e-40ea-9bc2-e20a7c29cb81.jpg?_t=1726907812', ref: '/refs/chavoshi.wav' }, |
|
|
{ id: 'ferdosipour', name: 'عادل فردوسیپور', img: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/1000188207.jpg?_t=1725334637', ref: '/refs/ferdosipour.wav' }, |
|
|
{ id: 'khiabani', name: 'جواد خیابانی', img: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/1000189552.jpg?_t=1726729222', ref: '/refs/khiabani.wav' }, |
|
|
{ id: 'musk', name: 'ایلان ماسک', img: 'https://app.puzzley.net/uploads/user/Jydo/%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/7ba0101a-83ed-46c3-a2e7-57a85e034b2c.jpg', ref: '/refs/musk.wav' }, |
|
|
{ id: 'maryam', name: 'مریم (اختصاصی آلفا)', img: 'https://app.puzzley.net/uploads/user/Jydo/%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/ab1e28b106df4c48ac228da6ced9d076.jpeg', ref: '/refs/maryam.wav' } |
|
|
]; |
|
|
|
|
|
|
|
|
const api = { |
|
|
async getFingerprint() { |
|
|
return 'fp_' + Math.random().toString(36).substr(2, 9); |
|
|
}, |
|
|
async checkUser(email) { |
|
|
if (!email) return { status: 'free' }; |
|
|
try { |
|
|
const res = await fetch('/tts/check_status.php', { |
|
|
method: 'POST', |
|
|
headers: {'Content-Type': 'application/json'}, |
|
|
body: JSON.stringify({email}) |
|
|
}); |
|
|
return await res.json(); |
|
|
} catch { return { status: 'free' }; } |
|
|
}, |
|
|
async sendCode(email) { |
|
|
return fetch('/tts/send_code.php', { |
|
|
method: 'POST', |
|
|
body: JSON.stringify({email}) |
|
|
}).then(r => r.json()); |
|
|
}, |
|
|
async verifyCode(email, code) { |
|
|
return fetch('/tts/verify_code.php', { |
|
|
method: 'POST', |
|
|
body: JSON.stringify({email, code}) |
|
|
}).then(r => r.json()); |
|
|
}, |
|
|
|
|
|
async generateTTS(payload) { |
|
|
const res = await fetch(`${PROXY_URL}?endpoint=generate`, { |
|
|
method: 'POST', |
|
|
headers: {'Content-Type': 'application/json'}, |
|
|
body: JSON.stringify(payload) |
|
|
}); |
|
|
if(res.status === 429) throw new Error("محدودیت اعتبار روزانه به پایان رسیده است."); |
|
|
if(!res.ok) throw new Error("خطا در تولید صدا"); |
|
|
return res.blob(); |
|
|
}, |
|
|
|
|
|
async uploadVoiceConversion(formData) { |
|
|
const res = await fetch(`${PROXY_URL}?endpoint=vc-upload`, { |
|
|
method: 'POST', |
|
|
body: formData |
|
|
}); |
|
|
if(!res.ok) { |
|
|
const err = await res.json(); |
|
|
throw new Error(err.message || "خطا در آپلود فایل"); |
|
|
} |
|
|
return res.json(); |
|
|
}, |
|
|
|
|
|
async checkVoiceStatus(jobId) { |
|
|
const res = await fetch(`${PROXY_URL}?endpoint=vc-status`, { |
|
|
method: 'POST', |
|
|
body: JSON.stringify({ job_id: jobId }) |
|
|
}); |
|
|
return res.json(); |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const GlassNav = ({ activeTab, onChange }) => { |
|
|
return ( |
|
|
<div className="sticky top-4 z-50 flex justify-center mb-8 px-4"> |
|
|
<nav className="glass-nav rounded-2xl p-1.5 flex items-center gap-1 shadow-lg max-w-full overflow-x-auto no-scrollbar"> |
|
|
<button |
|
|
onClick={() => onChange('tts')} |
|
|
className={`px-4 py-2.5 rounded-xl text-sm font-bold transition-all flex items-center gap-2 whitespace-nowrap |
|
|
${activeTab === 'tts' ? 'bg-white text-primary shadow-sm' : 'text-gray-500 hover:bg-white/50'}`} |
|
|
> |
|
|
<i className="fas fa-keyboard text-lg"></i> |
|
|
تبدیل متن به صدا |
|
|
</button> |
|
|
<button |
|
|
onClick={() => onChange('vc')} |
|
|
className={`px-4 py-2.5 rounded-xl text-sm font-bold transition-all flex items-center gap-2 whitespace-nowrap |
|
|
${activeTab === 'vc' ? 'bg-white text-primary shadow-sm' : 'text-gray-500 hover:bg-white/50'}`} |
|
|
> |
|
|
<i className="fas fa-microphone-alt text-lg"></i> |
|
|
تغییر صدا (AI) |
|
|
</button> |
|
|
<button |
|
|
onClick={() => onChange('podcast')} |
|
|
className={`px-4 py-2.5 rounded-xl text-sm font-bold transition-all flex items-center gap-2 whitespace-nowrap relative |
|
|
${activeTab === 'podcast' ? 'bg-white text-primary shadow-sm' : 'text-gray-500 hover:bg-white/50'}`} |
|
|
> |
|
|
<i className="fas fa-podcast text-lg"></i> |
|
|
ساخت پادکست |
|
|
<span className="absolute -top-1 -left-1 flex h-2.5 w-2.5"> |
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span> |
|
|
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-amber-500"></span> |
|
|
</span> |
|
|
<span className="text-[9px] bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded mr-1">بزودی</span> |
|
|
</button> |
|
|
</nav> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
|
|
|
const TTSComponent = ({ user, onLoginRequest }) => { |
|
|
const [text, setText] = useState(''); |
|
|
const [speaker, setSpeaker] = useState(TTS_SPEAKERS[0]); |
|
|
const [temp, setTemp] = useState(0.9); |
|
|
const [isLoading, setIsLoading] = useState(false); |
|
|
const [audioUrl, setAudioUrl] = useState(null); |
|
|
const [statusMsg, setStatusMsg] = useState(''); |
|
|
const [isSpeakerModalOpen, setIsSpeakerModalOpen] = useState(false); |
|
|
|
|
|
const handleGenerate = async () => { |
|
|
if (!user.email) return onLoginRequest(); |
|
|
if (!text.trim()) return alert("لطفا متن را وارد کنید"); |
|
|
|
|
|
setIsLoading(true); |
|
|
setAudioUrl(null); |
|
|
setStatusMsg(''); |
|
|
|
|
|
try { |
|
|
|
|
|
const blob = await api.generateTTS({ |
|
|
text, |
|
|
speaker: speaker.id, |
|
|
temperature: temp, |
|
|
email: user.email, |
|
|
fingerprint: user.fingerprint |
|
|
}); |
|
|
const url = URL.createObjectURL(blob); |
|
|
setAudioUrl(url); |
|
|
} catch (e) { |
|
|
setStatusMsg(e.message); |
|
|
} finally { |
|
|
setIsLoading(false); |
|
|
} |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<div className="animate-fade-in bg-white rounded-[24px] shadow-xl border border-gray-100 p-6 md:p-10 relative"> |
|
|
<div className="mb-6"> |
|
|
<label className="block font-bold text-gray-800 mb-2">📝 متن اصلی</label> |
|
|
<textarea |
|
|
value={text} |
|
|
onChange={(e) => setText(e.target.value)} |
|
|
rows={5} |
|
|
className="w-full p-4 rounded-xl border border-gray-200 bg-gray-50 focus:bg-white focus:border-primary outline-none transition-all resize-none" |
|
|
placeholder="متن خود را برای تبدیل به گفتار اینجا وارد کنید..." |
|
|
></textarea> |
|
|
<div className="text-left text-xs text-gray-400 mt-2 dir-ltr">{text.length} / 50000</div> |
|
|
</div> |
|
|
|
|
|
<div className="mb-6"> |
|
|
<label className="block font-bold text-gray-800 mb-2">🎤 گوینده منتخب</label> |
|
|
<div |
|
|
onClick={() => setIsSpeakerModalOpen(true)} |
|
|
className="flex items-center justify-between p-3 rounded-full border border-gray-200 bg-gray-50 cursor-pointer hover:border-primary transition-all max-w-md mx-auto" |
|
|
> |
|
|
<div className="flex items-center gap-3"> |
|
|
<img src={speaker.img} className="w-12 h-12 rounded-full object-cover border-2 border-white shadow-sm" /> |
|
|
<div className="text-right"> |
|
|
<h4 className="font-bold text-gray-800 text-sm">{speaker.name}</h4> |
|
|
<p className="text-xs text-gray-500">{speaker.desc}</p> |
|
|
</div> |
|
|
</div> |
|
|
<button className="bg-white text-primary text-xs font-bold px-4 py-2 rounded-full shadow-sm border border-gray-100">تغییر</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div className="mb-8"> |
|
|
<div className="flex items-center justify-between mb-2"> |
|
|
<label className="font-bold text-gray-800 flex items-center gap-2"> |
|
|
🌡️ خلاقیت و پویایی |
|
|
<span className="w-5 h-5 rounded-full bg-gray-100 text-gray-500 flex items-center justify-center text-xs cursor-help" title="مقادیر بالاتر صدای متنوعتری تولید میکنند">!</span> |
|
|
</label> |
|
|
<span className="font-bold text-primary bg-blue-50 px-2 py-1 rounded-lg text-sm">{temp}</span> |
|
|
</div> |
|
|
<input |
|
|
type="range" min="0.1" max="1.5" step="0.05" value={temp} |
|
|
onChange={(e) => setTemp(parseFloat(e.target.value))} |
|
|
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-primary" |
|
|
/> |
|
|
</div> |
|
|
|
|
|
<button |
|
|
onClick={handleGenerate} |
|
|
disabled={isLoading} |
|
|
className="w-full py-4 rounded-xl font-black text-lg text-white bg-gradient-to-r from-primary to-purple-600 hover:translate-y-[-4px] shadow-lg shadow-primary/30 transition-all flex items-center justify-center gap-3 disabled:opacity-70 disabled:cursor-not-allowed" |
|
|
> |
|
|
{isLoading ? <i className="fas fa-circle-notch fa-spin"></i> : <i className="fas fa-wand-magic-sparkles"></i>} |
|
|
{isLoading ? 'در حال پردازش...' : 'خلق صدا با آلفا'} |
|
|
</button> |
|
|
|
|
|
{/* Output Section */} |
|
|
<div className={`mt-8 p-6 rounded-2xl border-2 border-dashed border-gray-200 bg-gray-50 transition-all ${audioUrl ? 'border-primary bg-white shadow-lg border-solid' : ''}`}> |
|
|
{!audioUrl && !isLoading && !statusMsg && ( |
|
|
<div className="text-center text-gray-400 font-medium">صدای تولید شده در اینجا ظاهر خواهد شد.</div> |
|
|
)} |
|
|
{isLoading && ( |
|
|
<div className="flex flex-col items-center justify-center py-4"> |
|
|
<div className="w-16 h-16 border-4 border-primary/30 border-t-primary rounded-full animate-spin mb-4"></div> |
|
|
<p className="font-bold text-gray-600 animate-pulse">هوش مصنوعی در حال خواندن متن...</p> |
|
|
</div> |
|
|
)} |
|
|
{statusMsg && ( |
|
|
<div className="text-center text-red-500 bg-red-50 p-3 rounded-xl border border-red-100 font-bold">{statusMsg}</div> |
|
|
)} |
|
|
{audioUrl && ( |
|
|
<div className="animate-scale-in"> |
|
|
<p className="text-center text-green-600 font-bold mb-4">✅ صدا با موفقیت تولید شد</p> |
|
|
<audio controls src={audioUrl} className="w-full mb-4"></audio> |
|
|
<a href={audioUrl} download="aisada-tts.wav" className="flex items-center justify-center gap-2 w-full py-3 bg-secondary text-white rounded-xl font-bold shadow-lg shadow-teal-500/20 hover:bg-teal-500 transition-colors"> |
|
|
<i className="fas fa-download"></i> دانلود فایل |
|
|
</a> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
|
|
|
{/* Speaker Modal */} |
|
|
{isSpeakerModalOpen && ( |
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4"> |
|
|
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setIsSpeakerModalOpen(false)}></div> |
|
|
<div className="relative bg-white rounded-3xl w-full max-w-lg p-6 shadow-2xl max-h-[80vh] overflow-y-auto animate-scale-in"> |
|
|
<div className="flex justify-between items-center mb-6"> |
|
|
<h3 className="text-xl font-black text-gray-800">گالری گویندگان</h3> |
|
|
<button onClick={() => setIsSpeakerModalOpen(false)} className="text-2xl text-gray-400 hover:text-red-500">×</button> |
|
|
</div> |
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4"> |
|
|
{TTS_SPEAKERS.map(s => ( |
|
|
<div |
|
|
key={s.id} |
|
|
onClick={() => { setSpeaker(s); setIsSpeakerModalOpen(false); }} |
|
|
className={`cursor-pointer rounded-xl p-2 border-2 transition-all text-center |
|
|
${speaker.id === s.id ? 'border-primary bg-blue-50' : 'border-transparent hover:bg-gray-50'}`} |
|
|
> |
|
|
<img src={s.img} className="w-16 h-16 rounded-full mx-auto mb-2 object-cover border border-gray-200" /> |
|
|
<div className="font-bold text-sm text-gray-800">{s.name}</div> |
|
|
</div> |
|
|
))} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
|
|
|
const VoiceChangerComponent = ({ user, onLoginRequest }) => { |
|
|
const [selectedModel, setSelectedModel] = useState(null); |
|
|
const [file, setFile] = useState(null); |
|
|
const [isLoading, setIsLoading] = useState(false); |
|
|
const [resultUrl, setResultUrl] = useState(null); |
|
|
const [status, setStatus] = useState(''); |
|
|
const fileInputRef = useRef(null); |
|
|
|
|
|
const handleFileChange = (e) => { |
|
|
if (e.target.files && e.target.files[0]) { |
|
|
setFile(e.target.files[0]); |
|
|
setResultUrl(null); |
|
|
} |
|
|
}; |
|
|
|
|
|
const handleGenerate = async () => { |
|
|
if (!user.email) return onLoginRequest(); |
|
|
if (!selectedModel) return alert("لطفا یک مدل را انتخاب کنید"); |
|
|
if (!file) return alert("لطفا فایل صدای خود را آپلود کنید"); |
|
|
|
|
|
setIsLoading(true); |
|
|
setStatus('در حال ارسال فایل به سرور...'); |
|
|
setResultUrl(null); |
|
|
|
|
|
try { |
|
|
|
|
|
|
|
|
const refBlob = await fetch(selectedModel.ref).then(r => r.blob()); |
|
|
|
|
|
const formData = new FormData(); |
|
|
formData.append('email', user.email); |
|
|
formData.append('fingerprint', user.fingerprint); |
|
|
formData.append('source_audio', file); |
|
|
formData.append('ref_audio', refBlob, 'ref_audio.wav'); |
|
|
|
|
|
|
|
|
const uploadData = await api.uploadVoiceConversion(formData); |
|
|
const jobId = uploadData.job_id; |
|
|
|
|
|
|
|
|
setStatus('در حال پردازش هوشمند (ممکن است ۱ دقیقه طول بکشد)...'); |
|
|
|
|
|
const poll = setInterval(async () => { |
|
|
try { |
|
|
const statusData = await api.checkVoiceStatus(jobId); |
|
|
if (statusData.status === 'completed') { |
|
|
clearInterval(poll); |
|
|
|
|
|
setResultUrl(`https://ezmary-sada.hf.space/download/${statusData.filename}`); |
|
|
setIsLoading(false); |
|
|
setStatus(''); |
|
|
} else if (statusData.status === 'failed') { |
|
|
clearInterval(poll); |
|
|
throw new Error("خطا در پردازش سرور"); |
|
|
} |
|
|
} catch (e) { |
|
|
clearInterval(poll); |
|
|
setIsLoading(false); |
|
|
setStatus('خطا: ' + e.message); |
|
|
} |
|
|
}, 4000); |
|
|
|
|
|
} catch (e) { |
|
|
setIsLoading(false); |
|
|
setStatus('خطا: ' + e.message); |
|
|
} |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<div className="animate-fade-in bg-white rounded-[24px] shadow-xl border border-gray-100 p-6 md:p-10 relative"> |
|
|
<div className="text-center mb-8"> |
|
|
<h2 className="text-2xl font-black text-gray-800 mb-2">تغییر صدای جادویی</h2> |
|
|
<p className="text-gray-500 text-sm">صدای خود را به صدای خوانندگان و مشاهیر تبدیل کنید</p> |
|
|
</div> |
|
|
|
|
|
{/* Step 1: Model Selection */} |
|
|
<div className="mb-8"> |
|
|
<label className="block font-bold text-gray-800 mb-3">۱. انتخاب مدل صدا</label> |
|
|
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3 max-h-[280px] overflow-y-auto p-1 custom-scrollbar"> |
|
|
{VC_MODELS.map(model => ( |
|
|
<div |
|
|
key={model.id} |
|
|
onClick={() => setSelectedModel(model)} |
|
|
className={`cursor-pointer rounded-xl p-2 border-2 text-center transition-all flex flex-col items-center justify-center |
|
|
${selectedModel?.id === model.id ? 'border-primary bg-blue-50 shadow-md' : 'border-gray-100 hover:border-gray-300'}`} |
|
|
> |
|
|
<img src={model.img} className="w-14 h-14 rounded-full object-cover mb-2 border-2 border-white shadow-sm" /> |
|
|
<span className="text-[10px] font-bold text-gray-700 leading-tight">{model.name}</span> |
|
|
</div> |
|
|
))} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{/* Step 2: Upload */} |
|
|
<div className="mb-8"> |
|
|
<label className="block font-bold text-gray-800 mb-3">۲. آپلود صدای شما (ورودی)</label> |
|
|
<div |
|
|
onClick={() => fileInputRef.current?.click()} |
|
|
className={`border-2 border-dashed rounded-2xl p-6 text-center cursor-pointer transition-all bg-gray-50 hover:bg-white hover:border-primary |
|
|
${file ? 'border-green-400 bg-green-50' : 'border-gray-300'}`} |
|
|
> |
|
|
<input type="file" ref={fileInputRef} hidden accept="audio/*" onChange={handleFileChange} /> |
|
|
<div className="text-3xl mb-2">{file ? '🎤' : '📂'}</div> |
|
|
<p className="text-sm font-bold text-gray-700">{file ? 'فایل انتخاب شد' : 'برای انتخاب فایل کلیک کنید'}</p> |
|
|
<p className="text-xs text-gray-400 mt-1">{file ? file.name : '(کیفیت بالا، بدون نویز، ۳ تا ۱۰ ثانیه)'}</p> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{/* Step 3: Action */} |
|
|
<button |
|
|
onClick={handleGenerate} |
|
|
disabled={isLoading} |
|
|
className="w-full py-4 rounded-xl font-black text-lg text-white bg-gradient-to-r from-secondary to-teal-600 hover:translate-y-[-4px] shadow-lg shadow-teal-500/30 transition-all flex items-center justify-center gap-3 disabled:opacity-70 disabled:cursor-not-allowed" |
|
|
> |
|
|
{isLoading ? <i className="fas fa-circle-notch fa-spin"></i> : <i className="fas fa-microphone-lines"></i>} |
|
|
{isLoading ? 'در حال جادوگری...' : 'شروع تغییر صدا'} |
|
|
</button> |
|
|
|
|
|
{/* Output */} |
|
|
<div className="mt-8"> |
|
|
{status && ( |
|
|
<div className="text-center text-sm font-bold text-gray-500 animate-pulse mb-4">{status}</div> |
|
|
)} |
|
|
|
|
|
{resultUrl && ( |
|
|
<div className="animate-scale-in bg-gray-50 border border-gray-200 rounded-2xl p-6 text-center"> |
|
|
<div className="text-green-600 font-black text-lg mb-4 flex items-center justify-center gap-2"> |
|
|
<i className="fas fa-check-circle"></i> تغییر صدا انجام شد! |
|
|
</div> |
|
|
<audio controls src={resultUrl} className="w-full mb-4 shadow-sm rounded-full"></audio> |
|
|
<a href={resultUrl} download="voice-changed.wav" className="inline-block px-6 py-3 bg-primary text-white rounded-xl font-bold shadow-md hover:bg-blue-600 transition-colors"> |
|
|
دانلود فایل نهایی |
|
|
</a> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
|
|
|
{/* FAQ */} |
|
|
<div className="mt-10 border-t pt-6"> |
|
|
<h3 className="font-bold text-gray-800 mb-4 text-center">سوالات متداول</h3> |
|
|
<div className="space-y-3"> |
|
|
<div className="bg-gray-50 p-3 rounded-lg text-xs text-gray-600"> |
|
|
<strong>کیفیت خروجی چطور است؟</strong> |
|
|
<br/> |
|
|
به کیفیت فایل ورودی شما بستگی دارد. لطفا در محیط ساکت ضبط کنید. |
|
|
</div> |
|
|
<div className="bg-gray-50 p-3 rounded-lg text-xs text-gray-600"> |
|
|
<strong>آیا فایلم ذخیره میشود؟</strong> |
|
|
<br/> |
|
|
خیر، فایلها فقط برای پردازش استفاده شده و سپس حذف میشوند. |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
|
|
|
const PodcastComponent = () => { |
|
|
return ( |
|
|
<div className="animate-fade-in bg-gradient-to-br from-gray-900 to-gray-800 rounded-[24px] shadow-2xl p-10 text-center text-white relative overflow-hidden"> |
|
|
<div className="absolute top-0 left-0 w-full h-full opacity-20 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')]"></div> |
|
|
<div className="relative z-10 py-12"> |
|
|
<div className="w-24 h-24 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-6 backdrop-blur-md border border-white/20"> |
|
|
<i className="fas fa-podcast text-4xl text-amber-400"></i> |
|
|
</div> |
|
|
<h2 className="text-3xl font-black mb-4">استودیو ساخت پادکست</h2> |
|
|
<p className="text-gray-300 text-lg mb-8 max-w-md mx-auto leading-relaxed"> |
|
|
به زودی... یک انقلاب در تولید محتوای صوتی. |
|
|
<br/> |
|
|
سناریو بدهید، پادکست چند نفره تحویل بگیرید. |
|
|
</p> |
|
|
<span className="inline-block px-4 py-2 rounded-full bg-amber-500/20 text-amber-300 border border-amber-500/50 font-bold text-sm"> |
|
|
🚧 در حال توسعه |
|
|
</span> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
|
|
|
const AuthModal = ({ isOpen, onClose, onLoginSuccess }) => { |
|
|
const [step, setStep] = useState(1); |
|
|
const [email, setEmail] = useState(''); |
|
|
const [code, setCode] = useState(''); |
|
|
const [isLoading, setIsLoading] = useState(false); |
|
|
|
|
|
if (!isOpen) return null; |
|
|
|
|
|
const handleSendCode = async () => { |
|
|
if(!email) return alert("ایمیل را وارد کنید"); |
|
|
setIsLoading(true); |
|
|
try { |
|
|
const res = await api.sendCode(email); |
|
|
if(res.status === 'success') setStep(2); |
|
|
else alert(res.message); |
|
|
} catch(e) { alert("خطا در ارتباط"); } |
|
|
setIsLoading(false); |
|
|
}; |
|
|
|
|
|
const handleVerify = async () => { |
|
|
if(!code) return alert("کد را وارد کنید"); |
|
|
setIsLoading(true); |
|
|
try { |
|
|
const res = await api.verifyCode(email, code); |
|
|
if(res.status === 'success') { |
|
|
localStorage.setItem('userEmail', email); |
|
|
onLoginSuccess(email); |
|
|
onClose(); |
|
|
} else alert(res.message); |
|
|
} catch(e) { alert("خطا در تایید"); } |
|
|
setIsLoading(false); |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4"> |
|
|
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose}></div> |
|
|
<div className="relative bg-white rounded-3xl w-full max-w-sm p-8 shadow-2xl animate-scale-in text-center"> |
|
|
<button onClick={onClose} className="absolute top-4 right-4 text-2xl text-gray-300 hover:text-gray-600">×</button> |
|
|
<h2 className="text-xl font-black text-gray-800 mb-6">ورود / ثبت نام</h2> |
|
|
|
|
|
{step === 1 ? ( |
|
|
<> |
|
|
<p className="text-gray-500 text-sm mb-4">برای استفاده از امکانات، ایمیل خود را وارد کنید.</p> |
|
|
<input |
|
|
type="email" value={email} onChange={e=>setEmail(e.target.value)} |
|
|
className="w-full p-3 rounded-xl border border-gray-200 bg-gray-50 mb-4 text-center dir-ltr" |
|
|
placeholder="example@gmail.com" |
|
|
/> |
|
|
<button onClick={handleSendCode} disabled={isLoading} className="w-full py-3 bg-primary text-white rounded-xl font-bold hover:bg-blue-600 transition"> |
|
|
{isLoading ? '...' : 'ارسال کد تایید'} |
|
|
</button> |
|
|
</> |
|
|
) : ( |
|
|
<> |
|
|
<p className="text-gray-500 text-sm mb-4">کد ۶ رقمی ارسال شده به {email} را وارد کنید.</p> |
|
|
<input |
|
|
type="text" value={code} onChange={e=>setCode(e.target.value)} |
|
|
className="w-full p-3 rounded-xl border border-gray-200 bg-gray-50 mb-4 text-center tracking-[5px] font-bold text-xl" |
|
|
placeholder="123456" |
|
|
/> |
|
|
<button onClick={handleVerify} disabled={isLoading} className="w-full py-3 bg-primary text-white rounded-xl font-bold hover:bg-blue-600 transition"> |
|
|
{isLoading ? '...' : 'تایید و ورود'} |
|
|
</button> |
|
|
<button onClick={()=>setStep(1)} className="mt-4 text-xs text-gray-400">تغییر ایمیل</button> |
|
|
</> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
|
|
|
const App = () => { |
|
|
const [activeTab, setActiveTab] = useState('tts'); |
|
|
const [user, setUser] = useState({ email: null, status: 'free', fingerprint: null }); |
|
|
const [isAuthModalOpen, setIsAuthModalOpen] = useState(false); |
|
|
|
|
|
useEffect(() => { |
|
|
const init = async () => { |
|
|
const fp = await api.getFingerprint(); |
|
|
const storedEmail = localStorage.getItem('userEmail'); |
|
|
if (storedEmail) { |
|
|
const statusData = await api.checkUser(storedEmail); |
|
|
setUser({ email: storedEmail, status: statusData.status, fingerprint: fp }); |
|
|
} else { |
|
|
setUser(prev => ({ ...prev, fingerprint: fp })); |
|
|
} |
|
|
}; |
|
|
init(); |
|
|
}, []); |
|
|
|
|
|
const handleLogout = () => { |
|
|
localStorage.removeItem('userEmail'); |
|
|
fetch('/tts/logout.php'); |
|
|
window.location.reload(); |
|
|
}; |
|
|
|
|
|
const handleLoginSuccess = async (email) => { |
|
|
const statusData = await api.checkUser(email); |
|
|
setUser(prev => ({ ...prev, email, status: statusData.status })); |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<div className="page-wrapper min-h-screen pb-20"> |
|
|
{/* Header */} |
|
|
<header className="text-center mb-8 relative z-10 px-4"> |
|
|
<h1 className="text-3xl font-black mb-2 bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">هوش مصنوعی آلفا صدا</h1> |
|
|
<p className="text-gray-500 text-sm md:text-base">جعبه ابزار صوتی حرفهای: متن به صدا، تغییر صدا و پادکست</p> |
|
|
|
|
|
<div className="mt-4 flex justify-center"> |
|
|
{user.email ? ( |
|
|
<div className="flex items-center gap-3 bg-white px-4 py-2 rounded-full shadow-sm border border-gray-100"> |
|
|
<span className="font-bold text-xs text-gray-700">{user.email}</span> |
|
|
<span className={`text-[10px] font-bold px-2 py-0.5 rounded ${user.status === 'paid' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}> |
|
|
{user.status === 'paid' ? 'اشتراک ویژه' : 'رایگان'} |
|
|
</span> |
|
|
<button onClick={handleLogout} className="text-red-500 text-xs hover:font-bold"><i className="fas fa-sign-out-alt"></i></button> |
|
|
</div> |
|
|
) : ( |
|
|
<button onClick={() => setIsAuthModalOpen(true)} className="bg-white text-primary px-6 py-2 rounded-full font-bold text-sm shadow-md hover:translate-y-[-2px] transition-transform"> |
|
|
ورود / ثبت نام |
|
|
</button> |
|
|
)} |
|
|
</div> |
|
|
</header> |
|
|
|
|
|
{/* Tabs */} |
|
|
<GlassNav activeTab={activeTab} onChange={setActiveTab} /> |
|
|
|
|
|
{/* Content */} |
|
|
<div className="container mx-auto px-4 max-w-3xl"> |
|
|
{activeTab === 'tts' && <TTSComponent user={user} onLoginRequest={() => setIsAuthModalOpen(true)} />} |
|
|
{activeTab === 'vc' && <VoiceChangerComponent user={user} onLoginRequest={() => setIsAuthModalOpen(true)} />} |
|
|
{activeTab === 'podcast' && <PodcastComponent />} |
|
|
</div> |
|
|
|
|
|
<AuthModal |
|
|
isOpen={isAuthModalOpen} |
|
|
onClose={() => setIsAuthModalOpen(false)} |
|
|
onLoginSuccess={handleLoginSuccess} |
|
|
/> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
const rootElement = document.getElementById('root'); |
|
|
const root = ReactDOM.createRoot(rootElement); |
|
|
root.render(<App />); |
|
|
</script> |
|
|
</body> |
|
|
</html> |