dubbingAI / app.py
Ricky01anjay's picture
Update app.py
85efeac verified
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
# --- KONFIGURASI SILENT LOGS ---
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 = {}
# --- MAP SUARA (MALE & FEMALE) ---
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'}
}
# Mapping Bahasa untuk Prompt AI
LANG_MAP = {
'id-ID': 'Indonesia',
'en-US': 'Inggris',
'ja-JP': 'Jepang'
}
# Load Whisper (CPU Friendly, FP16 Fixed)
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
# Load audio dengan sample rate standard
y, sr = librosa.load(audio_path, sr=22050)
if len(y) == 0: return "Male", "+0Hz"
# Deteksi Fundamental Frequency (F0)
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)
# Threshold umum: > 165Hz = Perempuan, < 165Hz = Laki-laki
gender = "Female" if mean_f0 >= 165 else "Male"
# Hitung variasi pitch (agar tiap orang suaranya beda)
# Normal cowok ~120Hz, cewek ~210Hz. Dibagi 2 agar tidak terlalu ekstrem
base_f0 = 210.0 if gender == "Female" else 120.0
pitch_shift = int((mean_f0 - base_f0) / 2)
# Batasi modifikasi pitch Edge TTS agar tidak rusak (antara -20Hz s/d +20Hz)
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" # Default fallback
def translate_segments_llm(segments, custom_prompt, target_voice):
target_lang = LANG_MAP.get(target_voice, 'Indonesia')
# PERBAIKAN: Memasukkan bahasa target secara paksa ke dalam prompt
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
# PERBAIKAN: Menambahkan parameter pitch
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)})...'
# Pass target_voice ke translator
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
# Potong audio asli khusus untuk segmen ini guna deteksi suara
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)
# Deteksi Cewek/Cowok dan variasi pitch
gender, pitch_str = analyze_gender_and_pitch(chunk_wav)
# Pilih Voice ID yang sesuai berdasarkan bahasa dan gender
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")
# Generate TTS dengan pitch modifier
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)
# LOGIKA AUDIO BARU:
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)
# Cleanup file temporary
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)
# --- ROUTES ---
@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 DENGAN TAILWIND CSS ---
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)