לאחר זמן רב,
שישבתי יומם ולילה לחשוב איך כל אדם שצריך איזשהו קריינות, יוכל ליצור אותה בקלות.
לכן בניתי את האתר הזה
קודם כל יש להיכנס ל-Google AI Studio וליצור מפתח API חדש ולעלות לאתר למעלה בצד שמאל.
ואחרי זה תיצרו את הקריינות...
או שתפתחו פנקס רשימות ואז תשמרו את זה על שם index.html ותפתחו אותו...
<!DOCTYPE html>
<html lang="he" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VoiceMaster AI Studio ✨</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Assistant:wght@300;400;600;700;800&display=swap');
body { font-family: 'Assistant', sans-serif; background: #f1f5f9; transition: background-color 0.3s ease, color 0.3s ease; }
.dark-mode { background: #020617; color: #f8fafc; }
.card { background: white; border: 1px solid #e2e8f0; transition: all 0.3s ease; }
.dark-mode .card { background: #1e293b; border-color: #334155; }
.loading-spinner { border: 2px solid rgba(255,255,255,0.3); border-top: 2px solid currentColor; border-radius: 50%; width: 16px; height: 16px; animation: spin 0.8s linear infinite; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.ai-glow { box-shadow: 0 0 25px rgba(99, 102, 241, 0.15); }
.feature-badge { background: linear-gradient(90deg, #4f46e5, #9333ea); color: white; padding: 2px 8px; border-radius: 99px; font-size: 10px; font-weight: bold; }
/* התאמת רכיבי שמע למכשירים ניידים */
audio {
max-width: 100%;
height: 36px;
}
</style>
</head>
<body class="min-h-screen py-4 sm:py-8 px-3 sm:px-6 md:px-8 transition-colors duration-500" id="bodyTag">
<div class="max-w-7xl mx-auto">
<header class="flex flex-wrap justify-between items-center gap-4 mb-6 sm:mb-8 pb-4 border-b border-slate-200 dark:border-slate-800">
<div class="flex items-center gap-3 sm:gap-4">
<div class="bg-indigo-600 p-2.5 sm:p-3 rounded-2xl text-white shadow-xl flex-shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 sm:h-8 sm:w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
</svg>
</div>
<div>
<h1 class="text-2xl sm:text-3xl font-black text-slate-800 dark:text-white tracking-tight leading-none uppercase">Voice<span class="text-indigo-600">Master</span></h1>
<p class="text-[10px] sm:text-xs font-bold text-slate-400 uppercase tracking-widest mt-1">AI Smart Narration Hub</p>
</div>
</div>
<div class="flex items-center gap-2">
<div class="relative">
<button id="apiKeyBtn" class="p-2.5 sm:p-3 rounded-xl card shadow-sm hover:scale-105 transition-all flex items-center gap-2 text-xs font-bold text-slate-600 dark:text-slate-300">
<svg class="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/></svg>
<span id="apiKeyStatus" class="hidden sm:inline">הגדר מפתח</span>
</button>
<div id="apiKeyPopover" class="hidden absolute left-0 sm:left-auto right-0 sm:right-0 mt-2 w-72 sm:w-80 p-4 card rounded-2xl shadow-2xl z-50">
<h4 class="font-bold text-sm mb-2 dark:text-white">Gemini API Key</h4>
<input id="apiKeyInput" type="password" class="w-full p-2.5 text-xs bg-slate-50 dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 outline-none focus:ring-2 focus:ring-indigo-500 mb-2 dark:text-white" placeholder="AIzaSy...">
<div class="flex gap-2">
<button id="saveApiKey" class="flex-1 py-2 bg-indigo-600 text-white text-[11px] font-bold rounded-lg hover:bg-indigo-700 active:scale-95 transition-all">שמור מפתח</button>
<button id="clearApiKey" class="flex-1 py-2 bg-red-50 text-red-600 text-[11px] font-bold rounded-lg hover:bg-red-100 active:scale-95 transition-all">מחק</button>
</div>
</div>
</div>
<button id="toggleDark" class="p-2.5 sm:p-3 rounded-xl card shadow-sm hover:scale-105 active:scale-95 transition-all">
<svg class="h-5 w-5 sm:h-6 sm:w-6 text-slate-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
</svg>
</button>
</div>
</header>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6 lg:gap-8">
<div class="lg:col-span-5 space-y-6">
<div class="card p-4 sm:p-6 rounded-2xl sm:rounded-[2rem] shadow-lg ai-glow border-indigo-100 dark:border-indigo-900/30">
<div class="flex items-center justify-between mb-3 sm:mb-4">
<h3 class="font-bold text-sm sm:text-base text-indigo-900 dark:text-indigo-300">יצירת תוכן חכמה</h3>
</div>
<div class="flex gap-2 mb-3 sm:mb-4">
<input id="promptInput" type="text" class="flex-grow p-3 bg-slate-50 dark:bg-slate-800 rounded-xl text-xs sm:text-sm border-none outline-none focus:ring-2 focus:ring-indigo-500 dark:text-white" placeholder="תאר מה לכתוב (למשל: פרסומת לשירות חדש)...">
<button id="magicWriteBtn" class="p-3 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 active:scale-95 transition-all flex items-center justify-center min-w-[48px] sm:min-w-[50px]">
<span id="magicIcon">✨</span>
<div id="magicLoader" class="loading-spinner hidden"></div>
</button>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
<button onclick="smartEdit('הפוך את הטקסט ליותר רגוע ונעים')" class="text-[11px] font-bold py-2 px-1 bg-slate-100 dark:bg-slate-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-950/40 dark:text-slate-200 active:scale-95 transition-all truncate">✨ ריכוך</button>
<button onclick="smartEdit('הפוך את הטקסט ליותר שיווקי ואנרגטי')" class="text-[11px] font-bold py-2 px-1 bg-slate-100 dark:bg-slate-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-950/40 dark:text-slate-200 active:scale-95 transition-all truncate">✨ שיווקי</button>
<button onclick="smartEdit('קצר את הטקסט ב-30% מבלי לאבד משמעות')" class="text-[11px] font-bold py-2 px-1 bg-slate-100 dark:bg-slate-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-950/40 dark:text-slate-200 active:scale-95 transition-all truncate">✨ קיצור</button>
<button onclick="translateText()" class="text-[11px] font-bold py-2 px-1 bg-indigo-50 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300 rounded-lg hover:bg-indigo-100 active:scale-95 transition-all truncate">✨ תרגם לאנגלית</button>
<button onclick="generatePodcastScript()" class="text-[11px] font-bold py-2 px-1 bg-indigo-50 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300 rounded-lg hover:bg-indigo-100 active:scale-95 transition-all truncate">✨ תסריט פודקאסט</button>
<button onclick="summarizeText()" class="text-[11px] font-bold py-2 px-1 bg-indigo-50 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300 rounded-lg hover:bg-indigo-100 active:scale-95 transition-all truncate">✨ סיכום נקודות</button>
</div>
</div>
<div class="card p-4 sm:p-7 rounded-2xl sm:rounded-[2.5rem] shadow-xl border-none">
<div class="flex flex-wrap justify-between items-center gap-2 mb-2">
<label class="text-xs sm:text-sm font-bold text-slate-500 dark:text-slate-400">טקסט לקריינות</label>
<button id="analyzeToneBtn" class="text-[10px] sm:text-[11px] font-bold text-indigo-600 bg-indigo-50 dark:bg-indigo-900/30 px-2.5 py-1 rounded-full hover:bg-indigo-100 active:scale-95 transition-all flex items-center gap-1">
<span>✨ התאם סגנון אוטומטית</span>
<div id="toneLoader" class="loading-spinner hidden !w-3 !h-3"></div>
</button>
</div>
<textarea id="textInput" rows="5" class="w-full p-3 sm:p-4 bg-slate-50 dark:bg-slate-800/50 rounded-xl sm:rounded-2xl outline-none border-2 border-transparent focus:border-indigo-500 transition-all text-base sm:text-lg dark:text-white resize-y" placeholder="הטקסט יופיע כאן..."></textarea>
<div class="flex gap-2 mt-3 mb-5">
<button id="vocalizeBtn" class="flex-1 py-2.5 sm:py-3 bg-slate-50 dark:bg-slate-800 text-slate-600 dark:text-slate-300 rounded-xl text-xs font-bold hover:bg-indigo-50 dark:hover:bg-indigo-900/20 active:scale-95 transition-all flex items-center justify-center gap-2 border border-slate-100 dark:border-slate-700">
<span>✨ ניקוד אוטומטי</span>
<div id="vocalizeLoader" class="loading-spinner hidden"></div>
</button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4 mb-6">
<div>
<label class="text-[10px] font-bold text-slate-400 uppercase mr-1 block mb-1">קול</label>
<select id="voiceSelect" class="w-full p-3 bg-slate-100 dark:bg-slate-800 dark:text-white rounded-xl text-xs sm:text-sm font-bold border-none outline-none cursor-pointer">
<optgroup label="גברים (Male)">
<option value="Zephyr">Zephyr (קליל)</option>
<option value="Fenrir">Fenrir (עמוק)</option>
<option value="Puck">Puck (שובב)</option>
<option value="Charon">Charon (סמכותי)</option>
<option value="Orus">Orus (קלאסי)</option>
<option value="Enceladus">Enceladus (צעיר)</option>
<option value="Iapetus">Iapetus (חם)</option>
<option value="Algieba">Algieba (נמרץ)</option>
<option value="Rasalgethi">Rasalgethi (בוגר)</option>
<option value="Achernar">Achernar (יציב)</option>
<option value="Alnilam">Alnilam (בהיר)</option>
<option value="Schedar">Schedar (נעים)</option>
<option value="Achird">Achird (שידור)</option>
<option value="Zubenelgenubi">Zubenelgenubi (עמוק מאוד)</option>
<option value="Sadachbia">Sadachbia (רך)</option>
<option value="Sadaltager">Sadaltager (טבעי)</option>
<option value="Sulafat">Sulafat (עוצמתי)</option>
</optgroup>
<optgroup label="נשים (Female)">
<option value="Kore" selected>Kore (מקצועי)</option>
<option value="Leda">Leda (עדין)</option>
<option value="Aoede">Aoede (רך)</option>
<option value="Callirrhoe">Callirrhoe (רהוט)</option>
<option value="Autonoe">Autonoe (אנרגטי)</option>
<option value="Umbriel">Umbriel (מסתורי)</option>
<option value="Despina">Despina (מהיר)</option>
<option value="Erinome">Erinome (ידידותי)</option>
<option value="Algenib">Algenib (חדשותי)</option>
<option value="Laomedeia">Laomedeia (מרגיע)</option>
<option value="Gacrux">Gacrux (אלגנטי)</option>
<option value="Pulcherrima">Pulcherrima (עשיר)</option>
<option value="Vindemiatrix">Vindemiatrix (סבלני)</option>
</optgroup>
</select>
</div>
<div>
<label class="text-[10px] font-bold text-slate-400 uppercase mr-1 block mb-1">סגנון והגשה</label>
<select id="styleSelect" class="w-full p-3 bg-slate-100 dark:bg-slate-800 dark:text-white rounded-xl text-xs sm:text-sm font-bold border-none outline-none cursor-pointer">
<option value="natural">טבעי (Natural)</option>
<option value="cheerful">שמח (Cheerful)</option>
<option value="serious">רציני (Serious)</option>
<option value="whisper">לחישה (Whisper)</option>
<option value="excited">נרגש (Excited)</option>
<option value="calm">רגוע (Calm)</option>
<option value="authoritative">סמכותי (Authoritative)</option>
<option value="friendly">ידידותי (Friendly)</option>
<option value="professional">מקצועי (Professional)</option>
<option value="storytelling">מספר סיפורים (Storytelling)</option>
<option value="shouting">צעקה (Shouting)</option>
<option value="terrified">מבועת (Terrified)</option>
<option value="sad">עצוב (Sad)</option>
<option value="angry">כועס (Angry)</option>
<option value="hopeful">מלא תקווה (Hopeful)</option>
</select>
</div>
</div>
<button id="generateBtn" class="w-full bg-indigo-600 hover:bg-indigo-700 active:scale-[0.98] text-white font-bold py-3.5 sm:py-4 rounded-xl sm:rounded-2xl shadow-lg transition-all flex items-center justify-center gap-3">
<span id="btnText" class="text-sm sm:text-base">הפק קריינות עכשיו</span>
<div id="btnLoader" class="loading-spinner hidden !border-t-white"></div>
</button>
</div>
</div>
<div class="lg:col-span-7">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg sm:text-xl font-bold dark:text-white">הקלטות אחרונות</h2>
<button id="clearTracks" class="text-xs text-slate-400 hover:text-red-500 font-bold transition-colors">נקה הכל</button>
</div>
<div id="tracksContainer" class="space-y-3 sm:space-y-4">
<div id="noTracks" class="text-center py-16 sm:py-24 border-2 border-dashed border-slate-300 dark:border-slate-800 rounded-2xl sm:rounded-[2.5rem] text-slate-400 font-bold text-sm sm:text-base">
היסטוריית הקריינות שלך תישמר כאן
</div>
</div>
</div>
</div>
</div>
<div id="toast" class="fixed bottom-4 sm:bottom-8 left-4 sm:left-8 right-4 sm:right-auto px-4 sm:px-6 py-3 rounded-xl shadow-2xl opacity-0 transition-all pointer-events-none z-50 transform translate-y-4 flex items-center justify-center sm:justify-start gap-2 max-w-md">
<span id="toastIcon"></span>
<span id="toastMessage" class="text-xs sm:text-sm font-semibold"></span>
</div>
<script>
// Global State & Config
let currentApiKey = localStorage.getItem('gemini_api_key') || "";
const modelText = "gemini-3.5-flash";
const modelAudio = "gemini-2.5-flash-preview-tts";
const els = {
textInput: document.getElementById('textInput'),
promptInput: document.getElementById('promptInput'),
generateBtn: document.getElementById('generateBtn'),
magicWriteBtn: document.getElementById('magicWriteBtn'),
analyzeToneBtn: document.getElementById('analyzeToneBtn'),
voiceSelect: document.getElementById('voiceSelect'),
styleSelect: document.getElementById('styleSelect'),
tracksContainer: document.getElementById('tracksContainer'),
noTracks: document.getElementById('noTracks'),
body: document.getElementById('bodyTag'),
toast: document.getElementById('toast'),
toastMessage: document.getElementById('toastMessage'),
// API Key elements
apiKeyBtn: document.getElementById('apiKeyBtn'),
apiKeyPopover: document.getElementById('apiKeyPopover'),
apiKeyInput: document.getElementById('apiKeyInput'),
saveApiKey: document.getElementById('saveApiKey'),
clearApiKey: document.getElementById('clearApiKey'),
apiKeyStatus: document.getElementById('apiKeyStatus')
};
// Initialize UI
function updateApiStatus() {
if (currentApiKey) {
els.apiKeyStatus.innerText = "מפתח מוגדר ✅";
els.apiKeyStatus.classList.add('text-green-600');
els.apiKeyInput.value = currentApiKey;
} else {
els.apiKeyStatus.innerText = "הגדר מפתח ⚠️";
els.apiKeyStatus.classList.remove('text-green-600');
els.apiKeyInput.value = "";
}
}
updateApiStatus();
// API Key Popover Toggle
els.apiKeyBtn.onclick = (e) => {
e.stopPropagation();
els.apiKeyPopover.classList.toggle('hidden');
};
els.saveApiKey.onclick = () => {
const key = els.apiKeyInput.value.trim();
if (key) {
currentApiKey = key;
localStorage.setItem('gemini_api_key', key);
updateApiStatus();
els.apiKeyPopover.classList.add('hidden');
showToast("מפתח ה-API נשמר בהצלחה", "success");
}
};
els.clearApiKey.onclick = () => {
currentApiKey = "";
localStorage.removeItem('gemini_api_key');
updateApiStatus();
showToast("מפתח ה-API נמחק");
};
document.addEventListener('click', () => els.apiKeyPopover.classList.add('hidden'));
els.apiKeyPopover.onclick = (e) => e.stopPropagation();
// Utility: API Caller with retry logic
async function apiCall(model, payload, retries = 5) {
if (!currentApiKey) {
showToast("אנא הגדר מפתח API בתפריט למעלה");
els.apiKeyPopover.classList.remove('hidden');
throw new Error("Missing API Key");
}
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:${model.includes('tts') ? 'generateContent' : 'generateContent'}?key=${currentApiKey}`;
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json();
if (response.ok) return data;
if (response.status === 429) {
await new Promise(r => setTimeout(r, Math.pow(2, i) * 1000));
} else if (response.status === 403 || response.status === 401) {
showToast("מפתח ה-API אינו תקין");
throw new Error("Invalid API Key");
} else {
throw new Error(data.error?.message || "Communication Error");
}
} catch (e) {
if (i === retries - 1) throw e;
}
}
}
function showToast(msg, type = 'error') {
els.toastMessage.innerText = msg;
els.toast.className = `fixed bottom-4 sm:bottom-8 left-4 sm:left-8 right-4 sm:right-auto px-4 sm:px-6 py-3 rounded-xl shadow-2xl transition-all z-50 transform translate-y-0 opacity-100 ${type === 'error' ? 'bg-red-600' : 'bg-indigo-600'} text-white max-w-md flex items-center justify-center sm:justify-start gap-2`;
setTimeout(() => { els.toast.classList.add('opacity-0', 'translate-y-4'); }, 4000);
}
// --- LLM Features ---
// ✨ Magic Write
els.magicWriteBtn.onclick = async () => {
const prompt = els.promptInput.value.trim();
if (!prompt) return showToast("תאר מה לכתוב בתיבה");
toggleLoader('magic', true);
try {
const data = await apiCall(modelText, {
contents: [{ parts: [{ text: `כתוב סקריפט קצר ומקצועי לקריינות בעברית עבור הנושא הבא: "${prompt}". החזר רק את הטקסט של הסקריפט ללא הסברים או כותרות.` }] }]
});
const result = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (result) els.textInput.value = result.trim();
} catch (e) { if(e.message !== "Missing API Key") showToast(e.message); }
toggleLoader('magic', false);
};
// ✨ Smart Edit
async function smartEdit(instruction) {
const text = els.textInput.value.trim();
if (!text) return showToast("הכנס קודם טקסט לתיבה");
showToast("מעבד טקסט...", "success");
try {
const data = await apiCall(modelText, {
contents: [{ parts: [{ text: `${instruction} על הטקסט הבא: "${text}". החזר רק את הטקסט המעובד.` }] }]
});
const result = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (result) els.textInput.value = result.trim();
} catch (e) { if(e.message !== "Missing API Key") showToast(e.message); }
}
// ✨ Translate
async function translateText() {
const text = els.textInput.value.trim();
if (!text) return showToast("אין טקסט לתרגום");
showToast("מתרגם לאנגלית...", "success");
try {
const data = await apiCall(modelText, {
contents: [{ parts: [{ text: `Translate the following text to fluent English suitable for narration: "${text}". Return only the translation.` }] }]
});
const result = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (result) els.textInput.value = result.trim();
} catch (e) { if(e.message !== "Missing API Key") showToast(e.message); }
}
// ✨ Podcast Script
async function generatePodcastScript() {
const topic = els.promptInput.value.trim() || "Technology";
showToast("יוצר תסריט פודקאסט...", "success");
try {
const data = await apiCall(modelText, {
contents: [{ parts: [{ text: `Create a short podcast script dialogue between two people about: "${topic}". Return only the dialogue in Hebrew.` }] }]
});
const result = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (result) els.textInput.value = result.trim();
} catch (e) { if(e.message !== "Missing API Key") showToast(e.message); }
}
// ✨ Summarize
async function summarizeText() {
const text = els.textInput.value.trim();
if (!text) return showToast("אין טקסט לסיכום");
showToast("מסכם לנקודות...", "success");
try {
const data = await apiCall(modelText, {
contents: [{ parts: [{ text: `Summarize the following text into 3 clear key points for narration in Hebrew: "${text}".` }] }]
});
const result = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (result) els.textInput.value = result.trim();
} catch (e) { if(e.message !== "Missing API Key") showToast(e.message); }
}
// ✨ Tone Analysis
els.analyzeToneBtn.onclick = async () => {
const text = els.textInput.value.trim();
if (!text) return showToast("אין טקסט לניתוח");
toggleLoader('tone', true);
try {
const data = await apiCall(modelText, {
contents: [{ parts: [{ text: `Analyze the sentiment of the following text and pick the best speaking style from this list: (natural, cheerful, serious, whisper, excited, calm, authoritative, friendly, professional, storytelling, shouting, terrified, sad, angry, hopeful). Return ONLY the English word. Text: "${text}"` }] }]
});
const choice = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim().toLowerCase();
if (choice) {
const cleanChoice = choice.replace(/[^a-z]/g, '');
const option = Array.from(els.styleSelect.options).find(o => o.value === cleanChoice);
if (option) {
els.styleSelect.value = cleanChoice;
showToast(`הסגנון הותאם ל: ${option.text}`, "success");
}
}
} catch (e) { if(e.message !== "Missing API Key") showToast(e.message); }
toggleLoader('tone', false);
};
// ✨ Vocalization (Nikud)
document.getElementById('vocalizeBtn').onclick = async () => {
const text = els.textInput.value.trim();
if (!text) return showToast("אין טקסט לניקוד");
toggleLoader('vocalize', true);
try {
const data = await apiCall(modelText, {
contents: [{ parts: [{ text: `הוסף ניקוד דקדוקי מלא ומדויק לטקסט הבא בעברית: "${text}". החזר רק את הטקסט המנוקד.` }] }]
});
const result = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (result) els.textInput.value = result.trim();
} catch (e) { if(e.message !== "Missing API Key") showToast(e.message); }
toggleLoader('vocalize', false);
};
// --- TTS Core ---
els.generateBtn.onclick = async () => {
const text = els.textInput.value.trim();
if (!text) return showToast("הכנס טקסט לקריינות");
toggleLoader('btn', true);
try {
const result = await apiCall(modelAudio, {
contents: [{ parts: [{ text: `Say in a ${els.styleSelect.value} style: ${text}` }] }],
generationConfig: {
responseModalities: ["AUDIO"],
speechConfig: {
voiceConfig: {
prebuiltVoiceConfig: {
voiceName: els.voiceSelect.value
}
}
}
}
});
const pcmData = result.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
const mimeType = result.candidates?.[0]?.content?.parts?.[0]?.inlineData?.mimeType || "";
const sampleRate = parseInt(mimeType.split('rate=')[1]) || 24000;
if (pcmData) {
const blob = pcmToWav(pcmData, sampleRate);
addTrack(blob, text.substring(0, 30));
showToast("קריינות הופקה בהצלחה!", "success");
}
} catch (e) { if(e.message !== "Missing API Key") showToast(e.message); }
toggleLoader('btn', false);
};
// --- Helpers ---
function toggleLoader(key, show) {
const l = document.getElementById(key + 'Loader');
const i = document.getElementById(key + 'Icon');
const t = document.getElementById(key + 'Text');
if (l) l.classList.toggle('hidden', !show);
if (i) i.classList.toggle('hidden', show);
if (t) t.innerText = show ? "מעבד..." : "הפק קריינות עכשיו";
if (key === 'btn') els.generateBtn.disabled = show;
}
function pcmToWav(base64, rate) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
const header = new ArrayBuffer(44);
const v = new DataView(header);
const s = (off, str) => { for (let i = 0; i < str.length; i++) v.setUint8(off + i, str.charCodeAt(i)); };
s(0, 'RIFF'); v.setUint32(4, 36 + bytes.length, true); s(8, 'WAVE'); s(12, 'fmt '); v.setUint32(16, 16, true);
v.setUint16(20, 1, true); v.setUint16(22, 1, true); v.setUint32(24, rate, true); v.setUint32(28, rate * 2, true);
v.setUint16(32, 2, true); v.setUint16(34, 16, true); s(36, 'data'); v.setUint32(40, bytes.length, true);
return new Blob([header, bytes], { type: 'audio/wav' });
}
function addTrack(blob, title) {
els.noTracks.classList.add('hidden');
const url = URL.createObjectURL(blob);
const div = document.createElement('div');
div.className = "card p-3 sm:p-4 rounded-xl sm:rounded-2xl flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 shadow-sm border-slate-100 dark:border-slate-800";
div.innerHTML = `
<div class="flex-grow w-full sm:w-auto">
<div class="font-bold text-xs sm:text-sm truncate dark:text-white">${title}...</div>
<div class="text-[10px] text-slate-400 mt-0.5">${els.voiceSelect.value} | ${els.styleSelect.value}</div>
</div>
<div class="flex items-center justify-between sm:justify-end w-full sm:w-auto gap-2 border-t sm:border-t-0 pt-2 sm:pt-0 border-slate-100 dark:border-slate-800">
<audio src="${url}" controls class="w-full sm:w-48"></audio>
<a href="${url}" download="voice_${Date.now()}.wav" class="p-2 hover:bg-indigo-50 dark:hover:bg-slate-700 rounded-lg text-indigo-600 flex-shrink-0">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
</a>
</div>
`;
els.tracksContainer.prepend(div);
}
document.getElementById('toggleDark').onclick = () => els.body.classList.toggle('dark-mode');
document.getElementById('clearTracks').onclick = () => {
els.tracksContainer.querySelectorAll('.card').forEach(c => c.remove());
els.noTracks.classList.remove('hidden');
};
</script>
</body>
</html>
בהצלחה!!!