| import os |
| import uuid |
| import threading |
| import asyncio |
| import requests |
| import json |
| import time |
| import subprocess |
| import logging |
| import numpy as np |
| from flask import Flask, request, jsonify, render_template_string, send_from_directory |
| import whisper |
| import edge_tts |
|
|
| |
| os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3' |
| logging.getLogger('werkzeug').setLevel(logging.ERROR) |
|
|
| app = Flask(__name__) |
| BASE_DIR = os.path.dirname(os.path.abspath(__file__)) |
| UPLOAD_FOLDER = os.path.join(BASE_DIR, 'uploads') |
| app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER |
| os.makedirs(UPLOAD_FOLDER, exist_ok=True) |
|
|
| tasks = {} |
|
|
| |
| VOICE_MAP = { |
| 'id-ID': {'Male': 'id-ID-ArdiNeural', 'Female': 'id-ID-GadisNeural'}, |
| 'en-US': {'Male': 'en-US-ChristopherNeural', 'Female': 'en-US-AriaNeural'}, |
| 'ja-JP': {'Male': 'ja-JP-KeitaNeural', 'Female': 'ja-JP-NanamiNeural'} |
| } |
|
|
| |
| LANG_MAP = { |
| 'id-ID': 'Indonesia', |
| 'en-US': 'Inggris', |
| 'ja-JP': 'Jepang' |
| } |
|
|
| |
| whisper_model = whisper.load_model("base") |
|
|
| def get_audio_duration(file_path): |
| cmd = [ |
| 'ffprobe', '-v', 'error', '-show_entries', 'format=duration', |
| '-of', 'default=noprint_wrappers=1:nokey=1', file_path |
| ] |
| result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) |
| try: |
| return float(result.stdout) |
| except: |
| return 0.0 |
|
|
| def analyze_gender_and_pitch(audio_path): |
| """Menganalisis potongan audio untuk menentukan gender dan variasi pitch.""" |
| try: |
| import librosa |
| |
| y, sr = librosa.load(audio_path, sr=22050) |
| |
| if len(y) == 0: return "Male", "+0Hz" |
|
|
| |
| f0 = librosa.yin(y, fmin=65, fmax=300) |
| valid_f0 = f0[~np.isnan(f0)] |
| |
| if len(valid_f0) > 0: |
| mean_f0 = np.mean(valid_f0) |
| |
| |
| gender = "Female" if mean_f0 >= 165 else "Male" |
| |
| |
| |
| base_f0 = 210.0 if gender == "Female" else 120.0 |
| pitch_shift = int((mean_f0 - base_f0) / 2) |
| |
| |
| pitch_shift = max(-20, min(20, pitch_shift)) |
| pitch_str = f"+{pitch_shift}Hz" if pitch_shift >= 0 else f"{pitch_shift}Hz" |
| |
| return gender, pitch_str |
| except Exception as e: |
| print(f"Pitch analysis warning: {e}") |
| |
| return "Male", "+0Hz" |
|
|
| def translate_segments_llm(segments, custom_prompt, target_voice): |
| target_lang = LANG_MAP.get(target_voice, 'Indonesia') |
| |
| |
| if custom_prompt: |
| instruction = f"{custom_prompt}\n\nPENTING: Terjemahkan SEMUA teks ke dalam bahasa {target_lang}." |
| else: |
| instruction = f"Terjemahkan teks dalam JSON ini ke bahasa {target_lang} dengan akurat. Balas HANYA dengan JSON array." |
| |
| input_data = [{"id": i, "text": s['text']} for i, s in enumerate(segments)] |
| full_prompt = f"{instruction}\n\nFormat: [{{'id': 0, 'text': '...'}}]\n\nData:\n{json.dumps(input_data)}" |
|
|
| url = "https://www.puruboy.kozow.com/api/ai/notegpt" |
| payload = {"prompt": full_prompt, "model": "gemini-3-flash-preview", "chat_mode": "standard"} |
|
|
| try: |
| response = requests.post(url, json=payload, timeout=60) |
| full_text = "" |
| for line in response.iter_lines(): |
| if line: |
| decoded = line.decode('utf-8') |
| if decoded.startswith("data: "): |
| data = json.loads(decoded[6:]) |
| full_text += data.get("text", "") |
| |
| start_idx = full_text.find('[') |
| end_idx = full_text.rfind(']') + 1 |
| translated_list = json.loads(full_text[start_idx:end_idx]) |
| for item in translated_list: |
| segments[item['id']]['translated_text'] = item['text'] |
| except Exception as e: |
| print(f"Translation Error: {e}") |
| for s in segments: s['translated_text'] = s['text'] |
| return segments |
|
|
| |
| async def generate_tts(text, voice, path, pitch_str="+0Hz"): |
| communicate = edge_tts.Communicate(text, voice, pitch=pitch_str) |
| await communicate.save(path) |
|
|
| def process_dubbing(task_id, video_path, target_voice, custom_prompt): |
| try: |
| tasks[task_id]['status'] = 'Mengekstrak Audio...' |
| orig_audio = os.path.join(app.config['UPLOAD_FOLDER'], f"{task_id}_orig.wav") |
| subprocess.run(['ffmpeg', '-loglevel', 'quiet', '-y', '-i', video_path, '-vn', '-acodec', 'pcm_s16le', '-ar', '44100', orig_audio], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) |
|
|
| tasks[task_id]['status'] = 'Transkripsi...' |
| result = whisper_model.transcribe(orig_audio, verbose=False, fp16=False) |
| segments = result['segments'] |
|
|
| tasks[task_id]['status'] = f'Translasi AI ({LANG_MAP.get(target_voice, target_voice)})...' |
| |
| translated_segments = translate_segments_llm(segments, custom_prompt, target_voice) |
|
|
| tasks[task_id]['status'] = 'Menganalisis Suara & Dubbing...' |
| processed_audio_files = [] |
| |
| for i, seg in enumerate(translated_segments): |
| start_t = seg['start'] |
| end_t = seg['end'] |
| duration_orig = end_t - start_t |
| text = seg.get('translated_text', seg['text']) |
| if not text.strip(): continue |
|
|
| |
| chunk_wav = os.path.join(app.config['UPLOAD_FOLDER'], f"{task_id}_chunk_{i}.wav") |
| subprocess.run(['ffmpeg', '-loglevel', 'quiet', '-y', '-i', orig_audio, '-ss', str(start_t), '-t', str(duration_orig), chunk_wav], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) |
|
|
| |
| gender, pitch_str = analyze_gender_and_pitch(chunk_wav) |
| |
| |
| selected_voice = VOICE_MAP.get(target_voice, VOICE_MAP['id-ID'])[gender] |
|
|
| raw_tts = os.path.join(app.config['UPLOAD_FOLDER'], f"{task_id}_raw_{i}.mp3") |
| sync_tts = os.path.join(app.config['UPLOAD_FOLDER'], f"{task_id}_sync_{i}.wav") |
| |
| |
| asyncio.run(generate_tts(text, selected_voice, raw_tts, pitch_str)) |
| |
| tts_dur = get_audio_duration(raw_tts) |
| speed = min(max(tts_dur / duration_orig, 0.7), 1.8) if duration_orig > 0 else 1.0 |
|
|
| subprocess.run(['ffmpeg', '-loglevel', 'quiet', '-y', '-i', raw_tts, '-filter:a', f'atempo={speed}', '-ar', '44100', sync_tts], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) |
| processed_audio_files.append({'path': sync_tts, 'start': start_t}) |
|
|
| tasks[task_id]['status'] = 'Mixing Audio & Rendering...' |
| output_filename = f"{task_id}_output.mp4" |
| output_path = os.path.join(app.config['UPLOAD_FOLDER'], output_filename) |
| |
| |
| filter_complex = "[0:a]equalizer=f=1000:width_type=o:w=2:g=-15,volume=0.4[bg];" |
| inputs_cmd = ['ffmpeg', '-loglevel', 'quiet', '-y', '-i', video_path] |
| amix_inputs = "[bg]" |
| |
| for i, item in enumerate(processed_audio_files): |
| idx = i + 1 |
| inputs_cmd.extend(['-i', item['path']]) |
| start_ms = int(item['start'] * 1000) |
| filter_complex += f"[{idx}:a]adelay={start_ms}|{start_ms},volume=3.0[dub{idx}];" |
| amix_inputs += f"[dub{idx}]" |
| |
| filter_complex += f"{amix_inputs}amix=inputs={len(processed_audio_files)+1}:duration=first:dropout_transition=0,volume=1.5[outa]" |
| |
| final_cmd = inputs_cmd + [ |
| '-filter_complex', filter_complex, '-map', '0:v', '-map', '[outa]', |
| '-c:v', 'libx264', '-preset', 'ultrafast', '-c:a', 'aac', '-b:a', '192k', output_path |
| ] |
| |
| subprocess.run(final_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) |
|
|
| |
| for file in os.listdir(app.config['UPLOAD_FOLDER']): |
| if task_id in file and not file.endswith("_output.mp4"): |
| try: os.remove(os.path.join(app.config['UPLOAD_FOLDER'], file)) |
| except: pass |
|
|
| tasks[task_id]['status'] = 'Selesai' |
| tasks[task_id]['result_video'] = f"/download/{output_filename}" |
|
|
| except Exception as e: |
| tasks[task_id]['status'] = 'Error' |
| tasks[task_id]['error_message'] = str(e) |
|
|
|
|
| |
|
|
| @app.route('/') |
| def index(): |
| return render_template_string(HTML_TEMPLATE) |
|
|
| @app.route('/generate', methods=['POST']) |
| def generate(): |
| file = request.files.get('video') |
| if not file: return jsonify({'error': 'No file'}) |
| task_id = str(uuid.uuid4()) |
| path = os.path.join(app.config['UPLOAD_FOLDER'], f"{task_id}.mp4") |
| file.save(path) |
| tasks[task_id] = {'status': 'Queued', 'result_video': None, 'error_message': None} |
| threading.Thread(target=process_dubbing, args=(task_id, path, request.form.get('voice'), request.form.get('prompt'))).start() |
| return jsonify({'task_id': task_id}) |
|
|
| @app.route('/status') |
| def status(): |
| return jsonify(tasks.get(request.args.get('task_id'), {})) |
|
|
| @app.route('/download/<f>') |
| def download(f): |
| return send_from_directory(app.config['UPLOAD_FOLDER'], f) |
|
|
| |
| HTML_TEMPLATE = """ |
| <!DOCTYPE html> |
| <html lang="id"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>AI Dubbing Pro</title> |
| <script src="https://cdn.tailwindcss.com"></script> |
| </head> |
| <body class="bg-gray-900 text-gray-100 min-h-screen flex items-center justify-center p-4 font-sans"> |
| |
| <div class="bg-gray-800 rounded-2xl shadow-2xl p-8 w-full max-w-md border border-gray-700"> |
| <h2 class="text-2xl font-bold text-center mb-2 text-white">๐๏ธ Dubbing Sync Pro</h2> |
| <p class="text-sm text-center text-gray-400 mb-6">Deteksi Gender & Multi-Speaker Auto-Pitch</p> |
| |
| <form id="uploadForm" class="space-y-4"> |
| <div> |
| <label class="block text-sm font-medium text-gray-300 mb-1">Upload Video (MP4)</label> |
| <input type="file" id="videoFile" accept="video/*" required |
| class="block w-full text-sm text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-700 focus:outline-none bg-gray-700 rounded-lg p-2 border border-gray-600"> |
| </div> |
| |
| <div> |
| <label class="block text-sm font-medium text-gray-300 mb-1">Bahasa Target</label> |
| <select id="targetVoice" class="w-full bg-gray-700 border border-gray-600 rounded-lg p-2.5 text-white focus:ring-2 focus:ring-blue-500 focus:outline-none"> |
| <option value="id-ID">Indonesia ๐ฎ๐ฉ</option> |
| <option value="en-US">English ๐บ๐ธ</option> |
| <option value="ja-JP">Japanese ๐ฏ๐ต</option> |
| </select> |
| </div> |
| |
| <div> |
| <label class="block text-sm font-medium text-gray-300 mb-1">Custom Prompt AI (Opsional)</label> |
| <textarea id="customPrompt" rows="2" placeholder="Gaya bahasa santai, dll..." |
| class="w-full bg-gray-700 border border-gray-600 rounded-lg p-2.5 text-white focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"></textarea> |
| </div> |
| |
| <button type="submit" id="btnSubmit" |
| class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-4 rounded-lg transition duration-200 shadow-lg shadow-blue-500/30"> |
| Mulai Dubbing |
| </button> |
| </form> |
| |
| <!-- Loading State --> |
| <div id="loadingSection" class="hidden mt-6 flex flex-col items-center justify-center space-y-3"> |
| <svg class="animate-spin h-8 w-8 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> |
| <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> |
| <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
| </svg> |
| <span id="statusText" class="text-blue-400 font-medium tracking-wide">Menyiapkan...</span> |
| </div> |
| |
| <!-- Result State --> |
| <div id="resultSection" class="hidden mt-6 space-y-4"> |
| <video id="resVideo" controls class="w-full rounded-lg border border-gray-600 bg-black"></video> |
| <a id="dlBtn" href="#" download |
| class="block text-center w-full bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-4 rounded-lg transition duration-200 shadow-lg shadow-green-500/30"> |
| โฌ๏ธ Download Video |
| </a> |
| </div> |
| </div> |
| |
| <script> |
| const form = document.getElementById('uploadForm'); |
| form.onsubmit = async (e) => { |
| e.preventDefault(); |
| const fd = new FormData(); |
| fd.append('video', document.getElementById('videoFile').files[0]); |
| fd.append('voice', document.getElementById('targetVoice').value); |
| fd.append('prompt', document.getElementById('customPrompt').value); |
| |
| // UI Changes |
| document.getElementById('btnSubmit').disabled = true; |
| document.getElementById('btnSubmit').classList.add('opacity-50', 'cursor-not-allowed'); |
| document.getElementById('loadingSection').classList.remove('hidden'); |
| document.getElementById('resultSection').classList.add('hidden'); |
| |
| const res = await fetch('/generate', { method: 'POST', body: fd }); |
| const data = await res.json(); |
| |
| const timer = setInterval(async () => { |
| const sRes = await fetch('/status?task_id=' + data.task_id); |
| const sData = await sRes.json(); |
| |
| document.getElementById('statusText').innerText = sData.status; |
| |
| if (sData.status === 'Selesai') { |
| clearInterval(timer); |
| document.getElementById('loadingSection').classList.add('hidden'); |
| document.getElementById('resultSection').classList.remove('hidden'); |
| document.getElementById('resVideo').src = sData.result_video; |
| document.getElementById('dlBtn').href = sData.result_video; |
| |
| // Reset button |
| document.getElementById('btnSubmit').disabled = false; |
| document.getElementById('btnSubmit').classList.remove('opacity-50', 'cursor-not-allowed'); |
| } else if (sData.status === 'Error') { |
| clearInterval(timer); |
| alert("Terjadi Kesalahan: " + sData.error_message); |
| location.reload(); |
| } |
| }, 2000); |
| }; |
| </script> |
| </body> |
| </html> |
| """ |
|
|
| if __name__ == '__main__': |
| app.run(host='0.0.0.0', port=7860) |