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 */}
{/* --- SIDEBAR FIXED --- */}
{/* --- MAIN AREA --- */}
{isMultiSpeaker ? "Dual Voice Narrative" : `Voice: ${voices.find(v => v.id === selectedVoice)?.name}`}
{lastAudio && (
HASIL PRODUKSI TERAKHIR
{String(lastAudio.voice || '-')}
{String(lastAudio.emotion || '-')}
)}
{activeTab === 'history' && (
)}
);
};
export default App;INDO VOICE AI
Studio Gold • v13.5
{isGenerating && (
{String(statusMessage)}
)}
{error && {String(error)}
}
Katalog Tersimpan
{history.map((item) => (
{String(item.voice || '-')}
{String(item.emotion || '-')}
Disimpan di Cloud
))}
"{String(item.fullText || item.text)}"
{String(item.timestamp)}