Skip to content

Code Canvas: Voice Over Pro #25

@marwanzaen

Description

@marwanzaen

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;

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions