import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { initializeApp } from 'firebase/app'; import { getAuth, signInWithCustomToken, signInAnonymously, onAuthStateChanged } from 'firebase/auth'; import { getFirestore, doc, setDoc, getDoc, collection, onSnapshot, addDoc, updateDoc, deleteDoc, query } from 'firebase/firestore'; import { Volume2, Play, Download, Loader2, MessageSquare, Settings2, History, Trash2, Mic2, Sparkles, UserPlus, UserMinus, Quote, Share2, Info, ChevronRight, Headphones, Wand2, Search, User, UserCheck, RefreshCcw, BookOpen, Radio, Tv, Heart, Ghost, AlertTriangle, Zap, Newspaper, Mic, Users, ArrowRightLeft, Globe, Copy, XCircle, CheckCircle2, Cloud, Waves, SearchX } from 'lucide-react'; // --- CONFIGURATION GLOBAL --- const MAX_CHARS = 1000; const firebaseConfig = JSON.parse(__firebase_config); const app = initializeApp(firebaseConfig); const auth = getAuth(app); const db = getFirestore(app); const appId = 'voice-ai-studio-pro-v13-5-final'; const App = () => { // 1. Database const voices = useMemo(() => [ { id: 'Enceladus', name: 'Eginanjar', desc: 'Berat & Menggelegar', gender: 'Laki-laki' }, { id: 'Algenib', name: 'Budi', desc: 'Profesional & Jelas', gender: 'Laki-laki' }, { id: 'Iapetus', name: 'Ustadz Rahmat', desc: 'Bijak & Berwibawa', gender: 'Laki-laki' }, { id: 'Fenrir', name: 'Reza', desc: 'Modern & Tegas', gender: 'Laki-laki' }, { id: 'Algieba', name: 'Bambang', desc: 'Deep & Berwibawa', gender: 'Laki-laki' }, { id: 'Schedar', name: 'Hendra', desc: 'Tegas & Berwibawa', gender: 'Laki-laki' }, { id: 'Puck', name: 'Adit', desc: 'Muda & Ceria', gender: 'Laki-laki' }, { id: 'Orus', name: 'Suroto', desc: 'Stabil & Informatif', gender: 'Laki-laki' }, { id: 'Umbriel', name: 'Dimas', desc: 'Tenang & Kalem', gender: 'Laki-laki' }, { id: 'Rasalgethi', name: 'Kakek Jati', desc: 'Tua & Bergetar', gender: 'Laki-laki' }, { id: 'Alnilam', name: 'Rian', desc: 'Cepat & Aktif', gender: 'Laki-laki' }, { id: 'Aoede', name: 'Siska', desc: 'Jernih & Feminin', gender: 'Perempuan' }, { id: 'Leda', name: 'Kartini', desc: 'Anggun & Sopan', gender: 'Perempuan' }, { id: 'Zephyr', name: 'Lestari', desc: 'Energik & Fresh', gender: 'Perempuan' }, { id: 'Kore', name: 'Maya', desc: 'Dewasa & Ramah', gender: 'Perempuan' }, { id: 'Callirrhoe', name: 'Siti', desc: 'Lembut & Kalem', gender: 'Perempuan' }, { id: 'Autonoe', name: 'Indah', desc: 'Melodis & Manis', gender: 'Perempuan' }, { id: 'Despina', name: 'Ratna', desc: 'Tenang & Elegan', gender: 'Perempuan' }, { id: 'Erinome', name: 'Wati', desc: 'Hangat & Keibuan', gender: 'Perempuan' }, { id: 'Laomedeia', name: 'Dewi', desc: 'Ceria & Bersahabat', gender: 'Perempuan' }, { id: 'Sulafat', name: 'Ayu', desc: 'Sangat Lembut', gender: 'Perempuan' } ], []); const emotions = useMemo(() => [ { id: 'Neutral', label: 'Netral', icon: , prompt: 'Bicaralah dengan nada biasa.' }, { id: 'News', label: 'Berita TV', icon: , prompt: 'Gaya pembawa berita tegas.' }, { id: 'Documentary', label: 'Narasi TV', icon: , prompt: 'Gaya narator dokumenter.' }, { id: 'Radio', label: 'Radio', icon: , prompt: 'Gaya penyiar radio ceria.' }, { id: 'Ceramah', label: 'Ceramah', icon: , prompt: 'Gaya ustadz bijaksana.' }, { id: 'Sales', label: 'Promosi', icon: , prompt: 'Gaya iklan persuasif.' }, { id: 'Poetic', label: 'Puitis', icon: , prompt: 'Gaya membaca puisi.' }, { id: 'Mysterious', label: 'Misterius', icon: , prompt: 'Nada misterius gelap.' }, { id: 'Angry', label: 'Tegas/Marah', icon: , prompt: 'Nada sangat tegas.' }, { id: 'Excited', label: 'Ceria', icon: , prompt: 'Nada sangat antusias.' }, { id: 'Whisper', label: 'Berbisik', icon: , prompt: 'Bisikan pelan.' }, { id: 'Sad', label: 'Sedih', icon: , prompt: 'Nada sedih empati.' } ], []); // 2. States const [user, setUser] = useState(null); const [text, setText] = useState('Speaker1: Selamat datang di Indo Voice AI. Kami berkomitmen untuk menghadirkan teknologi pengolahan suara terbaik bagi industri kreatif Indonesia.\nSpeaker2: Kami percaya bahwa kualitas audio yang mumpuni adalah kunci dalam membangun narasi konten yang kuat di era digital ini.'); const [isGenerating, setIsGenerating] = useState(false); const [statusMessage, setStatusMessage] = useState(''); const [history, setHistory] = useState([]); const [lastAudio, setLastAudio] = useState(null); const [selectedVoice, setSelectedVoice] = useState('Algenib'); const [emotion, setEmotion] = useState('Neutral'); const [isMultiSpeaker, setIsMultiSpeaker] = useState(true); const [speaker1, setSpeaker1] = useState('Schedar'); const [speaker1Emotion, setSpeaker1Emotion] = useState('Neutral'); const [speaker2, setSpeaker2] = useState('Aoede'); const [speaker2Emotion, setSpeaker2Emotion] = useState('Neutral'); const [error, setError] = useState(null); const [activeTab, setActiveTab] = useState('create'); const [searchQuery, setSearchQuery] = useState(''); const [showCopyFeedback, setShowCopyFeedback] = useState(false); // 3. Logic const filteredVoices = useMemo(() => { return voices.filter(v => v.name.toLowerCase().includes(searchQuery.toLowerCase())); }, [voices, searchQuery]); const handleCopyText = useCallback(() => { try { const el = document.createElement('textarea'); el.value = text; document.body.appendChild(el); el.select(); document.execCommand('copy'); document.body.removeChild(el); setShowCopyFeedback(true); setTimeout(() => setShowCopyFeedback(false), 2000); } catch (e) { console.error(e); } }, [text]); const pcmToWav = (pcmData, sampleRate = 24000) => { const buffer = new ArrayBuffer(44 + pcmData.length * 2); const view = new DataView(buffer); const writeS = (o, s) => { for (let i = 0; i < s.length; i++) view.setUint8(o + i, s.charCodeAt(i)); }; writeS(0, 'RIFF'); view.setUint32(4, 32 + pcmData.length * 2, true); writeS(8, 'WAVE'); writeS(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, 1, true); view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate * 2, true); view.setUint16(32, 2, true); view.setUint16(34, 16, true); writeS(36, 'data'); view.setUint32(40, pcmData.length * 2, true); for (let i = 0; i < pcmData.length; i++) { view.setInt16(44 + i * 2, pcmData[i], true); } return new Blob([buffer], { type: 'audio/wav' }); }; // 4. Auth & Sync useEffect(() => { const init = async () => { try { if (typeof __initial_auth_token !== 'undefined' && __initial_auth_token) { await signInWithCustomToken(auth, __initial_auth_token); } else { await signInAnonymously(auth); } } catch (e) { console.error(e); } }; init(); const unsub = onAuthStateChanged(auth, setUser); return () => unsub(); }, []); useEffect(() => { if (!user) return; const ref = collection(db, 'artifacts', appId, 'users', user.uid, 'history'); const unsub = onSnapshot(ref, (snap) => { const items = snap.docs.map(doc => ({ id: doc.id, ...doc.data() })); setHistory(items.sort((a, b) => (b.timestamp_ms || 0) - (a.timestamp_ms || 0))); }); return () => unsub(); }, [user]); // 5. TTS Engine const generateSpeech = async () => { if (!text.trim() || isGenerating) return; setIsGenerating(true); setStatusMessage('Memproses Narasi AI...'); setError(null); const apiKey = ""; const s1 = voices.find(v => v.id === speaker1); const s1e = emotions.find(e => e.id === speaker1Emotion); const s2 = voices.find(v => v.id === speaker2); const s2e = emotions.find(e => e.id === speaker2Emotion); const curV = voices.find(v => v.id === selectedVoice); const curE = emotions.find(e => e.id === emotion); // Hapus instruksi nama karakter/gender agar AI tidak roleplay. // Fokus hanya menyuruh AI membaca naskah dengan emosi yang dipilih. let finalPrompt = isMultiSpeaker ? `Instruksi gaya bicara:\nSpeaker1: ${s1e.prompt}\nSpeaker2: ${s2e.prompt}\n\nTeks:\n${text}` : `Instruksi gaya bicara: ${curE.prompt}\n\nTeks:\n${text}`; let speechConfig = isMultiSpeaker ? { multiSpeakerVoiceConfig: { speakerVoiceConfigs: [ { speaker: "Speaker1", voiceConfig: { prebuiltVoiceConfig: { voiceName: speaker1 } } }, { speaker: "Speaker2", voiceConfig: { prebuiltVoiceConfig: { voiceName: speaker2 } } } ] } } : { voiceConfig: { prebuiltVoiceConfig: { voiceName: selectedVoice } } }; const runFetch = async (retries = 4, delay = 1500) => { const ctrl = new AbortController(); const tid = setTimeout(() => ctrl.abort(), 45000); try { const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-tts:generateContent?key=${apiKey}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: finalPrompt }] }], generationConfig: { responseModalities: ["AUDIO"], speechConfig: speechConfig }, model: "gemini-2.5-flash-preview-tts" }), signal: ctrl.signal }); clearTimeout(tid); if (!res.ok) { const raw = await res.text(); let errMsg = 'AI Server Busy'; try { errMsg = JSON.parse(raw).error.message; } catch(e) {} // Jika error 400 (Bad Request), langsung lempar error tanpa auto-retry if (res.status === 400) { throw new Error(`Format Ditolak: ${errMsg}`); } throw new Error(errMsg); } return await res.json(); } catch (err) { clearTimeout(tid); // Hanya lakukan retry jika BUKAN error 400 (Format Ditolak) if (retries > 0 && !err.message.includes('Format Ditolak')) { setStatusMessage(`Koneksi sibuk, coba lagi... (${retries})`); await new Promise(r => setTimeout(r, delay)); return runFetch(retries - 1, delay * 2); } throw err; } }; try { const result = await runFetch(); const part = result?.candidates?.[0]?.content?.parts?.find(p => p.inlineData); const audioData = part?.inlineData?.data; if (!audioData) throw new Error('AI gagal mengirimkan data suara.'); const bin = atob(audioData); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); const pcm16 = new Int16Array(bytes.buffer.slice(0, bin.length - (bin.length % 2))); const rate = part?.inlineData?.mimeType?.match(/rate=(\d+)/) ? parseInt(part.inlineData.mimeType.match(/rate=(\d+)/)[1]) : 24000; const blob = pcmToWav(pcm16, rate); const url = URL.createObjectURL(blob); const entry = { text: text.substring(0, 45) + '...', fullText: text, voice: isMultiSpeaker ? `${s1.name} & ${s2.name}` : curV.name, emotion: isMultiSpeaker ? 'Dual Mood' : curE.label, timestamp: new Date().toLocaleTimeString(), timestamp_ms: Date.now() }; if (user) await addDoc(collection(db, 'artifacts', appId, 'users', user.uid, 'history'), entry); setLastAudio({ ...entry, url }); setStatusMessage('Sukses!'); setTimeout(() => setStatusMessage(''), 2000); } catch (err) { setError(err.message); setStatusMessage(''); } finally { setIsGenerating(false); } }; const removeHistory = async (id) => { if (!user) return; try { await deleteDoc(doc(db, 'artifacts', appId, 'users', user.uid, 'history', id)); } catch (e) {} }; return (
{/* HEADER */}

INDO VOICE AI

{/* --- SIDEBAR FIXED --- */} {/* --- MAIN AREA --- */}
Studio Gold • v13.5 {isGenerating && (
{String(statusMessage)}
)}
{error &&
{String(error)}
}
{isMultiSpeaker ? "Dual Voice Narrative" : `Voice: ${voices.find(v => v.id === selectedVoice)?.name}`}