Naskah Skenario
AI merapihkan naskah...
import React, { useState, useRef, useEffect } from 'react'; import { Mic, Play, Download, Volume2, Loader2, Settings2, Info, FileAudio, Wand2, Activity, FastForward, Save, Music, MonitorPlay, Video, SlidersHorizontal, Layers, Type, Maximize2, X } from 'lucide-react'; // Konfigurasi API const apiKey = ""; // Daftar Suara (Nama telah dilokalisasi agar mudah diingat) const VOICES = [ { id: 'Enceladus', name: 'Garda', desc: 'Pria - Suara trailer bioskop, sangat berat & epik' }, { id: 'Aoede', name: 'Ayu', desc: 'Wanita - Hangat, lembut, dan menenangkan' }, { id: 'Charon', name: 'Bima', desc: 'Pria - Suara berat, tegas, dan berwibawa' }, { id: 'Fenrir', name: 'Arya', desc: 'Pria - Kuat, sedikit serak, narasi epik' }, { id: 'Kore', name: 'Sinta', desc: 'Wanita - Jelas, tenang, dan profesional' }, { id: 'Puck', name: 'Dika', desc: 'Pria - Ceria, muda, dan energetik' }, { id: 'Zephyr', name: 'Rian', desc: 'Pria - Santai, kasual, dan bersahabat' }, { id: 'Leda', name: 'Tari', desc: 'Wanita - Cerah, antusias, dan dinamis' }, { id: 'Orus', name: 'Surya', desc: 'Pria - Formal, gaya penyiar berita' }, { id: 'Algieba', name: 'Maya', desc: 'Wanita - Keibuan, ramah, dan peduli' }, { id: 'Algenib', name: 'Rio', desc: 'Pria - Lugas, to-the-point, praktis' }, { id: 'Schedar', name: 'Citra', desc: 'Wanita - Tajam, cerdas, percaya diri' }, { id: 'Gacrux', name: 'Wisnu', desc: 'Pria - Natural, gaya dokumenter alam' }, { id: 'Zubenelgenubi', name: 'Kevin', desc: 'Pria - Energetik untuk narasi iklan' }, { id: 'Sulafat', name: 'Nisa', desc: 'Wanita - Rileks, gaya percakapan santai' } ]; const EMOTIONS = [ { label: 'Normal', value: '' }, { label: 'Ceria (Cheerfully)', value: 'Say cheerfully: ' }, { label: 'Semangat (Excitedly)', value: 'Say excitedly: ' }, { label: 'Sedih (Sadly)', value: 'Say sadly: ' }, { label: 'Berbisik (Whisper)', value: 'Say in a whisper: ' }, { label: 'Marah (Angrily)', value: 'Say angrily: ' } ]; // --- FUNGSI UTILITAS AUDIO --- function pcmToWav(pcmBytes, sampleRate = 24000, numChannels = 1) { const buffer = new ArrayBuffer(44 + pcmBytes.length); const view = new DataView(buffer); const writeString = (view, offset, string) => { for (let i = 0; i < string.length; i++) view.setUint8(offset + i, string.charCodeAt(i)); }; writeString(view, 0, 'RIFF'); view.setUint32(4, 36 + pcmBytes.length, true); writeString(view, 8, 'WAVE'); writeString(view, 12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, numChannels, true); view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate * numChannels * 2, true); view.setUint16(32, numChannels * 2, true); view.setUint16(34, 16, true); writeString(view, 36, 'data'); view.setUint32(40, pcmBytes.length, true); const dataArray = new Uint8Array(buffer, 44); dataArray.set(pcmBytes); return new Blob([buffer], { type: 'audio/wav' }); } function base64ToUint8Array(base64) { const binaryString = atob(base64); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i); return bytes; } function audioBufferToWav(buffer) { const numChannels = buffer.numberOfChannels; const sampleRate = buffer.sampleRate; let result; if (numChannels === 2) { const channel1 = buffer.getChannelData(0); const channel2 = buffer.getChannelData(1); result = new Int16Array(channel1.length * 2); for (let i = 0; i < channel1.length; i++) { result[i * 2] = Math.max(-1, Math.min(1, channel1[i])) * 0x7FFF; result[i * 2 + 1] = Math.max(-1, Math.min(1, channel2[i])) * 0x7FFF; } } else { const channel1 = buffer.getChannelData(0); result = new Int16Array(channel1.length); for (let i = 0; i < channel1.length; i++) { result[i] = Math.max(-1, Math.min(1, channel1[i])) * 0x7FFF; } } return pcmToWav(new Uint8Array(result.buffer), sampleRate, numChannels); } function createReverbBuffer(audioCtx, duration, decay) { const sampleRate = audioCtx.sampleRate; const length = sampleRate * duration; const impulse = audioCtx.createBuffer(2, length, sampleRate); const impulseL = impulse.getChannelData(0); const impulseR = impulse.getChannelData(1); for (let i = 0; i < length; i++) { impulseL[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, decay); impulseR[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, decay); } return impulse; } async function fetchWithRetry(url, options, maxRetries = 3) { for (let i = 0; i < maxRetries; i++) { try { const response = await fetch(url, options); if (!response.ok) throw new Error(`API Error: ${response.status}`); return await response.json(); } catch (error) { if (i === maxRetries - 1) throw error; await new Promise(res => setTimeout(res, 1000 * Math.pow(2, i))); } } } // --- KOMPONEN UTAMA --- export default function App() { const [text, setText] = useState(''); const [selectedVoice, setSelectedVoice] = useState('Kore'); const [selectedEmotion, setSelectedEmotion] = useState(''); const [pitch, setPitch] = useState(1); const [playbackRate, setPlaybackRate] = useState(1); const [aiTempo, setAiTempo] = useState(0); // BARU: State untuk Tempo Bicara AI const [reverbIntensity, setReverbIntensity] = useState(0); // Slider Reverb (0 - 1) const [bgmFile, setBgmFile] = useState(null); const [bgmVolume, setBgmVolume] = useState(0.15); // Default BGM volume 15% const [showTeleprompter, setShowTeleprompter] = useState(false); const [isRecordingVideo, setIsRecordingVideo] = useState(false); const [isGenerating, setIsGenerating] = useState(false); const [isAssisting, setIsAssisting] = useState(false); const [audioUrl, setAudioUrl] = useState(null); const [errorMsg, setErrorMsg] = useState(''); const audioRef = useRef(null); const canvasRef = useRef(null); const audioCtxRef = useRef(null); const analyserRef = useRef(null); const sourceRef = useRef(null); const animFrameRef = useRef(null); const mediaRecorderRef = useRef(null); const textAreaRef = useRef(null); // Tambahan ref untuk textarea // Auto-Save useEffect(() => { const savedDraft = localStorage.getItem('vo_draft'); if (savedDraft) setText(savedDraft); }, []); useEffect(() => { const timeout = setTimeout(() => localStorage.setItem('vo_draft', text), 1000); return () => clearTimeout(timeout); }, [text]); useEffect(() => { if (audioRef.current) audioRef.current.playbackRate = playbackRate; }, [playbackRate, audioUrl]); // Visualizer Animation const setupVisualizer = () => { if (!audioRef.current || !canvasRef.current) return; if (!audioCtxRef.current) { const AudioContext = window.AudioContext || window.webkitAudioContext; audioCtxRef.current = new AudioContext(); analyserRef.current = audioCtxRef.current.createAnalyser(); analyserRef.current.fftSize = 256; sourceRef.current = audioCtxRef.current.createMediaElementSource(audioRef.current); sourceRef.current.connect(analyserRef.current); analyserRef.current.connect(audioCtxRef.current.destination); } const draw = () => { if (!canvasRef.current || !analyserRef.current) return; const canvas = canvasRef.current; const ctx = canvas.getContext('2d'); const bufferLength = analyserRef.current.frequencyBinCount; const dataArray = new Uint8Array(bufferLength); analyserRef.current.getByteFrequencyData(dataArray); ctx.fillStyle = '#0f172a'; ctx.fillRect(0, 0, canvas.width, canvas.height); const barWidth = (canvas.width / bufferLength) * 2.5; let barHeight; let x = 0; for(let i = 0; i < bufferLength; i++) { barHeight = dataArray[i] / 2; const gradient = ctx.createLinearGradient(0, canvas.height, 0, canvas.height - barHeight); gradient.addColorStop(0, '#4f46e5'); gradient.addColorStop(1, '#38bdf8'); ctx.fillStyle = gradient; ctx.beginPath(); ctx.roundRect(x, canvas.height - barHeight, barWidth - 1, barHeight, [4, 4, 0, 0]); ctx.fill(); x += barWidth; } animFrameRef.current = requestAnimationFrame(draw); }; draw(); }; useEffect(() => { return () => { if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); if (audioCtxRef.current) audioCtxRef.current.close(); }; }, []); // AI Assistant const handleAIAssistant = async (mode) => { if (!text.trim()) return setErrorMsg('Ketik naskah terlebih dahulu untuk menggunakan AI Assistant.'); setIsAssisting(true); setErrorMsg(''); let prompt = ''; if (mode === 'fix') prompt = `Perbaiki tata bahasa, ejaan, dan tanda baca dari naskah voice over ini. Buat agar nyaman dibaca. Naskah: "${text}"`; if (mode === 'persuasive') prompt = `Ubah naskah ini menjadi lebih persuasif, menarik, dan bersemangat untuk kebutuhan konten/iklan. Naskah: "${text}"`; if (mode === 'translate') prompt = `Terjemahkan naskah ini ke bahasa Inggris yang natural untuk Voice Over. Naskah: "${text}"`; try { const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=${apiKey}`; const payload = { contents: [{ parts: [{ text: prompt }] }] }; const result = await fetchWithRetry(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const newText = result.candidates?.[0]?.content?.parts?.[0]?.text; if (newText) setText(newText); } catch (err) { setErrorMsg('Gagal memproses naskah dengan AI.'); } finally { setIsAssisting(false); } }; // Fungsi utilitas untuk menyisipkan teks di posisi kursor const insertTextAtCursor = (textToInsert) => { if (!textAreaRef.current) return; const start = textAreaRef.current.selectionStart; const end = textAreaRef.current.selectionEnd; const newText = text.substring(0, start) + textToInsert + text.substring(end); setText(newText); setTimeout(() => { textAreaRef.current.focus(); textAreaRef.current.setSelectionRange(start + textToInsert.length, start + textToInsert.length); }, 0); }; // Fungsi untuk menyisipkan tag emosi dengan nama tampilan baru const insertEmotionTag = (emotionName) => { const currentVoiceName = VOICES.find(v => v.id === selectedVoice)?.name || 'Sinta'; insertTextAtCursor(`[${currentVoiceName}:${emotionName}] `); }; // Fungsi untuk menyisipkan tag jeda const insertJedaTag = (seconds) => { insertTextAtCursor(`[jeda:${seconds}] `); }; const getEmotionInstruction = (keyword) => { if (!keyword) return ''; const key = keyword.toLowerCase().trim(); const map = { 'ceria': 'Say cheerfully: ', 'semangat': 'Say excitedly: ', 'sedih': 'Say sadly: ', 'berbisik': 'Say in a whisper: ', 'marah': 'Say angrily: ', 'normal': '' }; return map[key] !== undefined ? map[key] : `Say ${key}: `; }; // Parser Pintar (Menerjemahkan nama Sinta/Dika kembali ke bahasa mesin) const parseScript = (inputText) => { if (!inputText.includes('[')) { return [{ type: 'speech', voiceId: selectedVoice, emotion: getEmotionInstruction(selectedEmotion), text: inputText.trim() }]; } const tokens = []; const regex = /\[(.*?)\]/g; let lastIndex = 0; let currentVoiceId = selectedVoice; let currentEmotion = getEmotionInstruction(selectedEmotion); let match; while ((match = regex.exec(inputText)) !== null) { // Ambil teks SEBELUM tag const textBefore = inputText.substring(lastIndex, match.index).trim(); if (textBefore) { tokens.push({ type: 'speech', voiceId: currentVoiceId, emotion: currentEmotion, text: textBefore }); } // Analisis Tag const tagContent = match[1].trim(); if (tagContent.toLowerCase().startsWith('jeda')) { const parts = tagContent.split(':'); const duration = parseFloat(parts[1]) || 1; tokens.push({ type: 'jeda', duration: duration }); } else { const parts = tagContent.split(':'); const typedName = parts[0].trim(); // Cari karakter berdasarkan nama mudah (misal: "Sinta") atau nama asli mesin const matchedVoice = VOICES.find(v => v.name.toLowerCase() === typedName.toLowerCase() || v.id.toLowerCase() === typedName.toLowerCase()); if (matchedVoice) currentVoiceId = matchedVoice.id; currentEmotion = parts[1] ? getEmotionInstruction(parts[1]) : ''; } lastIndex = regex.lastIndex; } // Ambil sisa teks SETELAH tag terakhir const remainingText = inputText.substring(lastIndex).trim(); if (remainingText) { tokens.push({ type: 'speech', voiceId: currentVoiceId, emotion: currentEmotion, text: remainingText }); } return tokens; }; // POST-PRODUCTION MIXER const applyStudioEffects = async (rawWavBlob, baseSampleRate) => { try { const AudioContext = window.AudioContext || window.webkitAudioContext; const tempCtx = new AudioContext(); const ttsArrayBuffer = await rawWavBlob.arrayBuffer(); const ttsBuffer = await tempCtx.decodeAudioData(ttsArrayBuffer); let bgmBuffer = null; if (bgmFile) { const bgmArrayBuffer = await bgmFile.arrayBuffer(); bgmBuffer = await tempCtx.decodeAudioData(bgmArrayBuffer); } const totalLength = Math.max(ttsBuffer.length, bgmBuffer ? ttsBuffer.length + (tempCtx.sampleRate * 2) : 0); const offlineCtx = new OfflineAudioContext(2, totalLength, tempCtx.sampleRate); const ttsSource = offlineCtx.createBufferSource(); ttsSource.buffer = ttsBuffer; let processedVoice = ttsSource; // Suara asli tanpa EQ // LOGIKA REVERB DINAMIS BERDASARKAN INTENSITAS if (reverbIntensity > 0) { const convolver = offlineCtx.createConvolver(); // Kalkulasi durasi dan pantulan secara dinamis (makin tinggi intensitas, makin panjang gemanya) const duration = 0.2 + (reverbIntensity * 2.3); // 0.2 detik hingga 2.5 detik const decay = 3.0 - (reverbIntensity * 1.5); // Pelepasan suara convolver.buffer = createReverbBuffer(offlineCtx, duration, decay); const fxGain = offlineCtx.createGain(); fxGain.gain.value = reverbIntensity * 0.5; // Maksimal 50% volume pantulan (wet mix) // Cabang efek: Masukkan suara ke dalam ruang Reverb processedVoice.connect(convolver); convolver.connect(fxGain); fxGain.connect(offlineCtx.destination); } // Hubungkan suara utama ke output final processedVoice.connect(offlineCtx.destination); ttsSource.start(0); if (bgmBuffer) { const bgmSource = offlineCtx.createBufferSource(); bgmSource.buffer = bgmBuffer; bgmSource.loop = true; const bgmGain = offlineCtx.createGain(); bgmGain.gain.value = bgmVolume; // Fade out BGM saat Voice Over selesai (Ducking Audio) bgmGain.gain.setValueAtTime(bgmVolume, Math.max(0, ttsBuffer.duration - 0.5)); bgmGain.gain.setTargetAtTime(0, ttsBuffer.duration, 0.5); bgmSource.connect(bgmGain); bgmGain.connect(offlineCtx.destination); bgmSource.start(0); } const renderedBuffer = await offlineCtx.startRendering(); tempCtx.close(); return audioBufferToWav(renderedBuffer); } catch (err) { console.error("Gagal mix audio, fallback ke raw:", err); return rawWavBlob; } }; // GENERATOR UTAMA const handleGenerate = async () => { if (!text.trim()) return setErrorMsg('Naskah tidak boleh kosong.'); setIsGenerating(true); setErrorMsg(''); setAudioUrl(null); try { const segments = parseScript(text); let allPcmData = []; const baseSampleRate = 24000; for (let segment of segments) { if (segment.type === 'jeda') { // Generate silent PCM for specific duration const silentSamples = new Uint8Array(baseSampleRate * 2 * segment.duration); allPcmData.push(silentSamples); continue; } if (segment.type === 'speech') { if (!segment.text.trim()) continue; const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-tts:generateContent?key=${apiKey}`; let finalPrompt = segment.emotion ? `${segment.emotion}${segment.text}` : segment.text; // Tambahkan instruksi tempo AI if (aiTempo === -2) finalPrompt = `Speak very slowly. ${finalPrompt}`; else if (aiTempo === -1) finalPrompt = `Speak slowly. ${finalPrompt}`; else if (aiTempo === 1) finalPrompt = `Speak quickly. ${finalPrompt}`; else if (aiTempo === 2) finalPrompt = `Speak very quickly. ${finalPrompt}`; const payload = { contents: [{ parts: [{ text: finalPrompt }] }], generationConfig: { responseModalities: ["AUDIO"], speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: segment.voiceId } } } }, model: "gemini-2.5-flash-preview-tts" }; const result = await fetchWithRetry(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const audioData = result.candidates?.[0]?.content?.parts?.[0]?.inlineData; if (audioData?.data) { allPcmData.push(base64ToUint8Array(audioData.data)); } else { throw new Error('Gagal mendapatkan sebagian audio dari server.'); } } } // Gabungkan PCM const totalLength = allPcmData.reduce((acc, arr) => acc + arr.length, 0); const combinedPcm = new Uint8Array(totalLength); let offset = 0; for (let arr of allPcmData) { combinedPcm.set(arr, offset); offset += arr.length; } const modifiedSampleRate = Math.round(baseSampleRate * pitch); const rawWavBlob = pcmToWav(combinedPcm, modifiedSampleRate); let finalWavBlob = rawWavBlob; if (bgmFile || reverbIntensity > 0) { finalWavBlob = await applyStudioEffects(rawWavBlob, modifiedSampleRate); } const urlObj = URL.createObjectURL(finalWavBlob); setAudioUrl(urlObj); } catch (err) { setErrorMsg(err.message || 'Terjadi kesalahan saat membuat voice over.'); } finally { setIsGenerating(false); } }; // Merekam Video Visualizer const handleRecordVideo = () => { if (!audioRef.current || !canvasRef.current) return; setIsRecordingVideo(true); const canvasStream = canvasRef.current.captureStream(30); if (!audioCtxRef.current) setupVisualizer(); const dest = audioCtxRef.current.createMediaStreamDestination(); sourceRef.current.connect(dest); const combinedStream = new MediaStream([...canvasStream.getVideoTracks(), ...dest.stream.getAudioTracks()]); mediaRecorderRef.current = new MediaRecorder(combinedStream, { mimeType: 'video/webm' }); const chunks = []; mediaRecorderRef.current.ondataavailable = e => chunks.push(e.data); mediaRecorderRef.current.onstop = () => { const blob = new Blob(chunks, { type: 'video/webm' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `VO_VideoExport_${Date.now()}.webm`; a.click(); setIsRecordingVideo(false); }; mediaRecorderRef.current.start(); audioRef.current.currentTime = 0; audioRef.current.play(); audioRef.current.onended = () => { if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') mediaRecorderRef.current.stop(); }; }; return (
{text.replace(/\[.*?\]/g, '') || "Naskah kosong. Ketik naskah Anda di editor terlebih dahulu."}
AI merapihkan naskah...