-
Notifications
You must be signed in to change notification settings - Fork 418
Open
Description
Salin teks dibawah, kemudian tempel di canvas gemini
import React, { useState, useRef } from 'react';
import {
Mic,
Download,
Play,
Settings2,
Type,
Volume2,
Loader2,
Radio,
Megaphone,
BookOpen,
Newspaper,
UserCircle2
} from 'lucide-react';
const App = () => {
const [text, setText] = useState('');
const [mode, setMode] = useState('preset'); // preset, manual, otomatis
const [preset, setPreset] = useState('iklan');
const [selectedVoice, setSelectedVoice] = useState('Kore');
const [audioUrl, setAudioUrl] = useState(null);
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState(null);
const apiKey = ""; // API key disediakan oleh environment
const voices = [
{ id: 'Kore', name: 'Kore (Versatile)', gender: 'Female' },
{ id: 'Charon', name: 'Charon (Deep/Formal)', gender: 'Male' },
{ id: 'Aoede', name: 'Aoede (Gentle)', gender: 'Female' },
{ id: 'Zephyr', name: 'Zephyr (Bright)', gender: 'Male' },
{ id: 'Puck', name: 'Puck (Playful)', gender: 'Neutral' },
];
const presets = {
iklan: {
label: 'Iklan (Ads)',
icon: <Megaphone className="w-5 h-5" />,
promptPrefix: "Say in an energetic, persuasive, and professional advertisement style: ",
description: "Suara bertenaga dan meyakinkan untuk promosi produk."
},
storytelling: {
label: 'Storytelling',
icon: <BookOpen className="w-5 h-5" />,
promptPrefix: "Say in an expressive, warm, and engaging storytelling voice with emotional depth: ",
description: "Nada bercerita yang dramatis dan emosional untuk narasi."
},
berita: {
label: 'Berita (News)',
icon: <Newspaper className="w-5 h-5" />,
promptPrefix: "Say in a formal, clear, objective, and authoritative news anchor style: ",
description: "Gaya bicara formal dan tegas seperti pembaca berita profesional."
}
};
// Fungsi pembantu untuk mengubah PCM16 ke WAV
const createWavFile = (pcmData, sampleRate) => {
const buffer = new ArrayBuffer(44 + pcmData.length * 2);
const view = new DataView(buffer);
const writeString = (offset, string) => {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
};
writeString(0, 'RIFF');
view.setUint32(4, 32 + pcmData.length * 2, true);
writeString(8, 'WAVE');
writeString(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);
writeString(36, 'data');
view.setUint32(40, pcmData.length * 2, true);
let offset = 44;
for (let i = 0; i < pcmData.length; i++, offset += 2) {
view.setInt16(offset, pcmData[i], true);
}
return new Blob([buffer], { type: 'audio/wav' });
};
const handleGenerate = async () => {
if (!text.trim()) {
setError("Mohon masukkan teks terlebih dahulu.");
return;
}
setIsGenerating(true);
setError(null);
setAudioUrl(null);
// Menentukan prompt berdasarkan mode
let finalPrompt = text;
let voiceToUse = selectedVoice;
if (mode === 'preset') {
finalPrompt = presets[preset].promptPrefix + text;
} else if (mode === 'otomatis') {
finalPrompt = `Analyze the context of this text and speak it in the most appropriate emotional tone and style: ${text}`;
}
const payload = {
contents: [{ parts: [{ text: finalPrompt }] }],
generationConfig: {
responseModalities: ["AUDIO"],
speechConfig: {
voiceConfig: {
prebuiltVoiceConfig: {
voiceName: voiceToUse
}
}
}
},
model: "gemini-2.5-flash-preview-tts"
};
const fetchWithRetry = async (retries = 5, delay = 1000) => {
try {
const response = 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(payload)
}
);
if (!response.ok) throw new Error('API request failed');
const result = await response.json();
const inlineData = result.candidates?.[0]?.content?.parts?.[0]?.inlineData;
if (!inlineData) throw new Error('No audio data received');
const base64Audio = inlineData.data;
const mimeType = inlineData.mimeType;
const sampleRateMatch = mimeType.match(/sample_rate=(\d+)/);
const sampleRate = sampleRateMatch ? parseInt(sampleRateMatch[1]) : 24000;
// Decode Base64 ke PCM16
const binaryString = atob(base64Audio);
const pcmData = new Int16Array(binaryString.length / 2);
for (let i = 0; i < pcmData.length; i++) {
pcmData[i] = (binaryString.charCodeAt(i * 2 + 1) << 8) | binaryString.charCodeAt(i * 2);
}
const wavBlob = createWavFile(pcmData, sampleRate);
const url = URL.createObjectURL(wavBlob);
setAudioUrl(url);
} catch (err) {
if (retries > 0) {
await new Promise(res => setTimeout(res, delay));
return fetchWithRetry(retries - 1, delay * 2);
}
throw err;
}
};
try {
await fetchWithRetry();
} catch (err) {
setError("Gagal menghasilkan suara. Silakan coba lagi.");
} finally {
setIsGenerating(false);
}
};
const downloadAudio = () => {
if (!audioUrl) return;
const a = document.createElement('a');
a.href = audioUrl;
a.download = `voiceover-${Date.now()}.wav`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
return (
<div className="min-h-screen bg-slate-50 p-4 md:p-8 font-sans text-slate-900">
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<header className="flex items-center justify-between mb-8">
<div className="flex items-center gap-3">
<div className="bg-indigo-600 p-2 rounded-xl text-white shadow-lg">
<Volume2 className="w-8 h-8" />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight text-indigo-950 uppercase">
Voice Studio Pro By <span className="text-blue-600">Teknalar</span>
</h1>
<p className="text-slate-500 text-sm">Professional Voice Over Generator</p>
</div>
</div>
</header>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Controls */}
<div className="lg:col-span-2 space-y-6">
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-6 space-y-4">
<div className="flex items-center gap-2 mb-2">
<Type className="w-5 h-5 text-indigo-500" />
<h2 className="font-semibold">Naskah Suara</h2>
</div>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Ketik atau tempel teks naskah Anda di sini..."
className="w-full h-48 p-4 rounded-xl bg-slate-50 border-transparent focus:bg-white focus:ring-2 focus:ring-indigo-500 transition-all resize-none outline-none text-lg"
/>
<div className="flex justify-between items-center text-xs text-slate-400">
<span>Mendukung berbagai bahasa</span>
<span>{text.length} Karakter</span>
</div>
</div>
{/* Results Section */}
{ (audioUrl || isGenerating || error) && (
<div className={`rounded-2xl shadow-sm border p-6 animate-in fade-in slide-in-from-bottom-4 duration-500 ${error ? 'bg-red-50 border-red-200' : 'bg-white border-slate-200'}`}>
{isGenerating ? (
<div className="flex flex-col items-center justify-center py-6 space-y-3">
<Loader2 className="w-10 h-10 text-indigo-600 animate-spin" />
<p className="text-sm font-medium text-slate-600">Sedang memproses suara...</p>
</div>
) : error ? (
<div className="text-red-600 text-center py-4">
<p className="font-medium">{error}</p>
<button onClick={handleGenerate} className="mt-2 text-sm underline">Coba lagi</button>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Play className="w-5 h-5 text-green-500 fill-green-500" />
<span className="font-medium">Pratinjau Suara</span>
</div>
<button
onClick={downloadAudio}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors shadow-sm"
>
<Download className="w-4 h-4" />
<span>Download WAV</span>
</button>
</div>
<audio src={audioUrl} controls className="w-full h-12 rounded-lg" />
</div>
)}
</div>
)}
</div>
{/* Sidebar Settings */}
<div className="space-y-6">
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-6 space-y-6">
<div>
<div className="flex items-center gap-2 mb-4">
<Settings2 className="w-5 h-5 text-indigo-500" />
<h2 className="font-semibold">Konfigurasi Gaya</h2>
</div>
<div className="flex p-1 bg-slate-100 rounded-lg mb-6">
{['preset', 'manual', 'otomatis'].map((m) => (
<button
key={m}
onClick={() => setMode(m)}
className={`flex-1 py-1.5 text-xs font-medium rounded-md capitalize transition-all ${
mode === m ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
{m}
</button>
))}
</div>
{mode === 'preset' && (
<div className="space-y-3">
{Object.entries(presets).map(([key, value]) => (
<button
key={key}
onClick={() => setPreset(key)}
className={`w-full flex items-start gap-3 p-3 rounded-xl border-2 text-left transition-all ${
preset === key ? 'border-indigo-500 bg-indigo-50' : 'border-slate-100 hover:border-slate-200'
}`}
>
<div className={`p-2 rounded-lg ${preset === key ? 'bg-indigo-500 text-white' : 'bg-slate-100 text-slate-500'}`}>
{value.icon}
</div>
<div>
<div className={`font-medium text-sm ${preset === key ? 'text-indigo-900' : 'text-slate-700'}`}>{value.label}</div>
<div className="text-[10px] text-slate-500 line-clamp-1">{value.description}</div>
</div>
</button>
))}
</div>
)}
{mode === 'manual' && (
<div className="space-y-4">
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Pilih Karakter Suara</label>
<div className="grid grid-cols-1 gap-2">
{voices.map((v) => (
<button
key={v.id}
onClick={() => setSelectedVoice(v.id)}
className={`flex items-center gap-3 p-3 rounded-xl border-2 text-left transition-all ${
selectedVoice === v.id ? 'border-indigo-500 bg-indigo-50' : 'border-slate-100 hover:border-slate-200'
}`}
>
<UserCircle2 className={`w-5 h-5 ${selectedVoice === v.id ? 'text-indigo-600' : 'text-slate-400'}`} />
<span className={`text-sm font-medium ${selectedVoice === v.id ? 'text-indigo-900' : 'text-slate-700'}`}>{v.name}</span>
</button>
))}
</div>
</div>
)}
{mode === 'otomatis' && (
<div className="p-4 bg-indigo-50 rounded-xl border border-indigo-100 text-center">
<div className="inline-block p-3 bg-indigo-600 rounded-full text-white mb-3 shadow-inner">
<Radio className="w-6 h-6 animate-pulse" />
</div>
<p className="text-sm text-indigo-900 font-medium">AI Smart Engine</p>
<p className="text-[11px] text-indigo-700 mt-1">AI akan menyesuaikan emosi dan intonasi secara otomatis berdasarkan konten teks Anda.</p>
</div>
)}
</div>
<button
disabled={isGenerating || !text.trim()}
onClick={handleGenerate}
className="w-full py-4 bg-slate-900 hover:bg-black disabled:bg-slate-300 text-white rounded-xl font-bold transition-all shadow-xl flex items-center justify-center gap-2 text-lg active:scale-[0.98]"
>
{isGenerating ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
<span>Processing...</span>
</>
) : (
<>
<Mic className="w-6 h-6" />
<span>Generate Voice</span>
</>
)}
</button>
</div>
<div className="bg-slate-900 rounded-2xl p-6 text-white overflow-hidden relative">
<div className="relative z-10">
<h3 className="font-bold text-sm mb-2">Pro Tips</h3>
<ul className="text-[11px] space-y-2 opacity-80">
<li>• Tambahkan tanda baca seperti koma (,) atau titik (.) untuk jeda yang lebih natural.</li>
<li>• Gunakan mode preset untuk hasil yang lebih konsisten sesuai kebutuhan media.</li>
<li>• Format unduhan adalah WAV kualitas tinggi 24kHz.</li>
</ul>
</div>
<div className="absolute -bottom-4 -right-4 opacity-10">
<Volume2 size={120} />
</div>
</div>
</div>
</div>
</div>
{/* Background Decor */}
<div className="fixed top-0 left-0 w-full h-1 bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 z-50"></div>
</div>
);
};
export default App;
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels