hoanglinhn0's picture
Update app.py
9a4b0cf verified
Raw
History Blame Contribute Delete
18.2 kB
import gradio as gr
import os
import re
import pysrt
import io
import wave
import math
import json
import concurrent.futures
from piper.voice import PiperVoice
from pydub import AudioSegment, effects
from pydub.silence import split_on_silence
from vinorm import TTSnorm
from huggingface_hub import hf_hub_download, list_repo_files
# --- 1. QUẢN LÝ MODEL TỪ HUGGING FACE ---
REPO_ID = "hoanglinhn0/Model"
voice_cache = {}
CONFIG_FILE = "config.json"
def load_config():
"""Đọc cấu hình từ file json, nếu không có trả về dict rỗng"""
if os.path.exists(CONFIG_FILE):
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except:
return {}
return {}
def save_config_to_file(voice, clean_opts, vol, speed, smart, overlap, ns, nw):
"""Lưu các tham số hiện tại vào file json"""
data = {
"voice": voice,
"clean_opts": clean_opts,
"vol": vol,
"speed": speed,
"smart": smart,
"overlap": overlap,
"ns": ns,
"nw": nw
}
try:
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=4)
except Exception as e:
print(f"Lỗi lưu config: {e}")
def get_voice_list():
try:
files = list_repo_files(repo_id=REPO_ID)
models = [f for f in files if f.endswith('.onnx')]
return sorted(models)
except Exception as e:
print(f"Lỗi lấy danh sách model: {e}")
return []
def load_piper_voice(model_name):
if model_name not in voice_cache:
print(f"Đang tải model: {model_name}...")
try:
onnx_path = hf_hub_download(repo_id=REPO_ID, filename=model_name)
config_path = hf_hub_download(repo_id=REPO_ID, filename=model_name + ".json")
voice_cache[model_name] = PiperVoice.load(onnx_path, config_path=config_path)
except Exception as e:
raise Exception(f"Không thể tải model {model_name}: {str(e)}")
return voice_cache[model_name]
# --- 2. HÀM LÀM SẠCH VĂN BẢN (ĐÃ TỐI ƯU CHO FILE SRT CỦA BẠN) ---
def advanced_text_cleaning(text, options=None):
if options is None: options = []
# --- A. Xử lý các thẻ Code/HTML/SRT rác trước ---
# 1. Xử lý HTML tags (quan trọng cho file có <font>, <b>...)
if "Ignore html tags" in options:
text = re.sub(r'<[^>]+>', ' ', text) # Thay bằng khoảng trắng để tránh dính chữ
text = re.sub(r'#[0-9a-fA-F]{6}', '', text) # Xóa mã màu hex nếu còn sót
# 2. Xử lý các loại ngoặc
if "Ignore text between parentheses ()" in options:
text = re.sub(r'\([^)]*\)', '', text)
if "Ignore text between curly brackets {}" in options:
text = re.sub(r'\{[^}]*\}', '', text)
if "Ignore text between square brackets []" in options:
# Xử lý các thẻ như
text = re.sub(r'\[[^\]]*\]', '', text)
if "Ignore text between asterisks * *" in options:
text = re.sub(r'\*[^*]*\*', '', text)
# 3. Xử lý Nốt nhạc (Logic thông minh hơn)
# Lưu ý: Nếu chọn tùy chọn này, nó sẽ xóa nội dung GIỮA nốt nhạc.
# Với file của bạn, KHÔNG NÊN chọn option này.
# Nhưng nếu người dùng chọn, code vẫn thực thi.
if "Ignore text between notes ♪ ♪" in options:
text = re.sub(r'♪[^♪]*♪', '', text)
# 4. Xử lý ký tự đặc biệt (Để xóa nốt nhạc ♪ nhưng giữ lại lời thoại)
if "Ignore characters: * # ~ ♪ + _ !" in options:
# Giữ lại dấu chấm than (!) để model thể hiện cảm xúc, xóa các ký tự rác khác bao gồm ♪
text = re.sub(r'[*#~♪+_]', ' ', text)
if "Ignore repetitions of non-alphabetic characters" in options:
text = re.sub(r'(\W)\1+', r'\1', text)
if "Ignore periods and commas ( . , )" in options:
text = re.sub(r'[.,]', ' ', text)
# --- B. Làm sạch khoảng trắng và định dạng ---
text = text.strip()
# Xóa ký tự gạch đầu dòng đầu câu (thường gặp trong sub)
text = re.sub(r'^[\-\–]\s+', '', text)
text = re.sub(r'\n[\-\–]\s+', '\n', text)
# --- C. CHẠY TTSNORM ---
try:
text = TTSnorm(text)
except:
pass
# --- D. XỬ LÝ DẤU CÂU (Tạo nhịp điệu đọc) ---
# Thay thế xuống dòng bằng dấu chấm để ngắt câu rõ ràng
text = re.sub(r'[\r\n]+', '. ', text)
# Thay dấu chấm phẩy/hai chấm bằng dấu phẩy
text = re.sub(r'[;:]', ',', text)
# Xóa ngoặc kép/đơn
text = re.sub(r'[\"\']', '', text)
# Chuẩn hóa dấu 3 chấm
text = re.sub(r'\.{2,}', '... ', text)
# --- E. XỬ LÝ KHOẢNG TRẮNG CUỐI CÙNG ---
# Thêm khoảng trắng sau dấu câu nếu bị dính (trừ số thập phân)
text = re.sub(r'(?<=[.,?!])(?=[^\s\d])', r' ', text)
# Xóa khoảng trắng thừa trước dấu câu
text = re.sub(r'\s+([.,?!])', r'\1', text)
# Gộp nhiều khoảng trắng thành 1
text = re.sub(r'\s+', ' ', text)
return text.strip()
# --- 3. XỬ LÝ ÂM THANH ---
def trim_silence(audio, silence_thresh=-40, min_silence_len=50):
try:
return audio.strip_silence(silence_thresh=silence_thresh)
except:
return audio
def process_loudness(audio, boost_factor):
try:
if boost_factor <= 1.0: return audio
audio = audio.high_pass_filter(100)
audio = effects.compress_dynamic_range(audio, threshold=-20.0, ratio=2.5)
gain_db = 20 * math.log10(boost_factor)
audio = audio + gain_db
audio = effects.compress_dynamic_range(audio, threshold=-1.0, ratio=10.0)
return effects.normalize(audio, headroom=0.1)
except:
return audio
def synthesize_single_segment(args):
text, voice_name, speed, noise, noise_w, volume_boost, sub_info = args
try:
voice = load_piper_voice(voice_name)
# Tách câu dựa trên dấu câu để tạo độ ngắt nghỉ tự nhiên
parts = re.split(r'([.,!?])', text)
combined_audio = AudioSegment.silent(duration=0)
for i in range(0, len(parts), 2):
part_text = parts[i].strip()
if not part_text:
continue
with io.BytesIO() as wav_io:
with wave.open(wav_io, "wb") as wav_file:
voice.synthesize(part_text, wav_file, length_scale=1.0/speed, noise_scale=noise, noise_w=noise_w)
wav_io.seek(0)
part_audio = AudioSegment.from_wav(wav_io)
combined_audio += part_audio
# Xử lý độ trễ (pause) dựa trên dấu câu
if i + 1 < len(parts):
punc = parts[i + 1]
if punc in ['.', '!', '?']:
combined_audio += AudioSegment.silent(duration=150) # Nghỉ hết câu
elif punc == ',':
combined_audio += AudioSegment.silent(duration=200) # Nghỉ dấu phẩy
audio = trim_silence(combined_audio)
audio = process_loudness(audio, volume_boost)
return (audio, sub_info)
except Exception as e:
print(f"Lỗi segment {sub_info['index']}: {e}")
return (AudioSegment.silent(duration=0), sub_info)
# --- TÍNH NĂNG: NGHE THỬ (PREVIEW) ---
def preview_speech(text, voice_name, speed, noise, noise_w, volume_boost, clean_opts):
try:
if not text.strip():
return "⚠️ Hãy nhập nội dung để test!", None
if not voice_name:
return "⚠️ Chưa chọn model!", None
clean_text = advanced_text_cleaning(text, clean_opts)
print(f"[Preview Debug] Cleaned Text: {clean_text}")
voice = load_piper_voice(voice_name)
parts = re.split(r'([.,!?])', clean_text)
combined_audio = AudioSegment.silent(duration=0)
for i in range(0, len(parts), 2):
part_text = parts[i].strip()
if not part_text: continue
with io.BytesIO() as wav_io:
with wave.open(wav_io, "wb") as wav_file:
voice.synthesize(part_text, wav_file, length_scale=1.0/speed, noise_scale=noise, noise_w=noise_w)
wav_io.seek(0)
part_audio = AudioSegment.from_wav(wav_io)
combined_audio += part_audio
if i + 1 < len(parts):
punc = parts[i + 1]
if punc in ['.', '!', '?']:
combined_audio += AudioSegment.silent(duration=150)
elif punc == ',':
combined_audio += AudioSegment.silent(duration=200)
audio = trim_silence(combined_audio)
audio = process_loudness(audio, volume_boost)
output_path = "preview_temp.wav"
audio.export(output_path, format="wav")
return "✅ Đã tạo mẫu thử!", output_path
except Exception as e:
return f"Lỗi Preview: {str(e)}", None
# --- 4. PIPELINE XỬ LÝ CHÍNH ---
def process_pipeline(voice_name, srt_file, manual_text, base_speed, noise, noise_w, clean_options, smart_speed, volume_boost, allow_overlap):
try:
if not voice_name: return "⚠️ Chưa chọn model (đang tải danh sách...)", None
is_srt = False
subs = []
if srt_file:
try:
subs = pysrt.open(srt_file.name, encoding='utf-8')
is_srt = True
except:
return "⚠️ Lỗi đọc file SRT!", None
elif manual_text.strip():
try:
temp_subs = pysrt.from_string(manual_text)
if len(temp_subs) > 0 and temp_subs[0].end.ordinal > 0:
subs = temp_subs
is_srt = True
else:
raise Exception("Not SRT")
except:
subs = manual_text.strip().split('\n')
is_srt = False
else:
return "⚠️ Không có dữ liệu!", None
tasks = []
for i, item in enumerate(subs):
text_raw = item.text if is_srt else item
clean_text = advanced_text_cleaning(text_raw, clean_options)
if not clean_text.strip(): continue
sub_info = {
'start': item.start.ordinal if is_srt else i * 1000,
'end': item.end.ordinal if is_srt else 0,
'index': i
}
tasks.append((clean_text, voice_name, base_speed, noise, noise_w, volume_boost, sub_info))
if not tasks:
return "⚠️ Không tìm thấy văn bản sau khi làm sạch! (Kiểm tra lại bộ lọc)", None
workers = min(4, os.cpu_count() or 2)
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
results = list(executor.map(synthesize_single_segment, tasks))
results.sort(key=lambda x: x[1]['index'])
final_audio = AudioSegment.silent(duration=0)
for segment, info in results:
if len(segment) == 0: continue
if is_srt:
start_ms = info['start']
if allow_overlap:
if start_ms > len(final_audio):
final_audio += AudioSegment.silent(duration=start_ms - len(final_audio))
final_audio = final_audio.overlay(segment, position=start_ms)
else:
if start_ms > len(final_audio):
final_audio += AudioSegment.silent(duration=start_ms - len(final_audio))
final_audio += segment
else:
final_audio += segment + AudioSegment.silent(duration=300)
output_path = "output_pro.wav"
final_audio.export(output_path, format="wav")
return "✅ Hoàn thành!", output_path
except Exception as e:
return f"Lỗi hệ thống: {str(e)}", None
# --- 5. GIAO DIỆN GRADIO ---
custom_css = """
.gradio-container {background-color: #f0faff;}
#header_title {text-align: center; color: #1e40af;}
.action-btn {background: linear-gradient(135deg, #60a5fa, #2563eb) !important; color: white !important;}
.test-btn {background: linear-gradient(135deg, #34d399, #059669) !important; color: white !important;}
.group-box {background: white; border-radius: 15px; padding: 20px; box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);}
"""
CLEANING_OPTIONS = [
"Ignore html tags",
"Ignore text between parentheses ()",
"Ignore text between curly brackets {}",
"Ignore text between square brackets []",
"Ignore text between asterisks * *",
"Ignore text between notes ♪ ♪",
"Ignore characters: * # ~ ♪ + _ !",
"Ignore repetitions of non-alphabetic characters",
"Ignore periods and commas ( . , )"
]
# CẤU HÌNH MẶC ĐỊNH ĐƯỢC TỐI ƯU CHO FILE CỦA BẠN:
# - Đã bỏ chọn "Ignore text between notes" để tránh mất tiếng
# - Đã chọn "Ignore html tags" và "brackets" để lọc rác
DEFAULT_CHECKED = [
"Ignore html tags",
"Ignore text between curly brackets {}",
"Ignore text between square brackets []",
"Ignore text between asterisks * *",
# "Ignore text between notes ♪ ♪", <-- ĐÃ BỎ CHỌN MẶC ĐỊNH
"Ignore characters: * # ~ ♪ + _ !"
]
user_settings = load_config()
with gr.Blocks() as demo:
gr.Markdown("# 🎙️ Piper High-Speed Mastering (Cleaned Text)", elem_id="header_title")
with gr.Row():
with gr.Column(scale=4):
with gr.Group(elem_classes="group-box"):
with gr.Row():
voice_choices = get_voice_list()
default_voice = user_settings.get("voice", voice_choices[0] if voice_choices else None)
v_select = gr.Dropdown(
choices=voice_choices,
label="Model (Từ Hugging Face)",
value=default_voice,
scale=4
)
refresh_btn = gr.Button("🔄 Tải DS", scale=1)
with gr.Tabs():
with gr.TabItem("📝 Văn bản / SRT"):
manual_input = gr.Textbox(label="Input", lines=10, placeholder="Dán nội dung...")
with gr.TabItem("📁 Tải File"):
srt_input = gr.File(label="SRT File")
with gr.Group(elem_classes="group-box"):
gr.Markdown("### 🧹 Làm sạch văn bản")
clean_checkboxes = gr.CheckboxGroup(
choices=CLEANING_OPTIONS,
value=user_settings.get("clean_opts", DEFAULT_CHECKED),
label="Tùy chọn lọc bỏ",
interactive=True
)
with gr.Column(scale=3):
with gr.Group(elem_classes="group-box"):
gr.Markdown("### ⚙️ Cài đặt")
vol_boost = gr.Slider(1.0, 5.0, value=user_settings.get("vol", 3.0), step=0.1, label="🔊 Độ to")
sp = gr.Slider(0.5, 3.0, value=user_settings.get("speed", 1.3), step=0.1, label="⏩ Tốc độ")
with gr.Row():
smart_spd = gr.Checkbox(label="Smart Speed", value=user_settings.get("smart", False))
allow_overlap = gr.Checkbox(label="Ghi đè (SRT)", value=user_settings.get("overlap", False))
with gr.Row():
ns = gr.Slider(0.1, 1.0, value=user_settings.get("ns", 0.6), label="Noise Scale")
nw = gr.Slider(0.1, 1.0, value=user_settings.get("nw", 0.8), label="Noise W")
with gr.Group(elem_classes="group-box"):
gr.Markdown("### 🎧 Nghe thử nhanh (Test Audio)")
test_text = gr.Textbox(label="Văn bản test", value="Xin chào, đây là bản thử nghiệm. 1.5 giây.", lines=2)
with gr.Row():
test_btn = gr.Button("🔊 Nghe Thử", variant="secondary", elem_classes="test-btn")
test_audio_out = gr.Audio(label="Audio Test", interactive=False, type="filepath")
test_status = gr.Markdown("")
gr.Markdown("---")
btn = gr.Button("🚀 CHẠY TOÀN BỘ FILE", variant="primary", elem_classes="action-btn")
with gr.Row():
status = gr.Textbox(label="Trạng thái xử lý")
audio_out = gr.Audio(label="Kết quả Final", interactive=False)
auto_updater = gr.Timer(value=60)
def refresh_voices(current_voice):
new_choices = get_voice_list()
next_val = current_voice if current_voice in new_choices else (new_choices[0] if new_choices else None)
return gr.update(choices=new_choices, value=next_val)
refresh_btn.click(refresh_voices, inputs=[v_select], outputs=[v_select])
auto_updater.tick(refresh_voices, inputs=[v_select], outputs=[v_select])
test_btn.click(
preview_speech,
inputs=[test_text, v_select, sp, ns, nw, vol_boost, clean_checkboxes],
outputs=[test_status, test_audio_out]
)
btn.click(process_pipeline,
[v_select, srt_input, manual_input, sp, ns, nw, clean_checkboxes, smart_spd, vol_boost, allow_overlap],
[status, audio_out])
settings_inputs = [v_select, clean_checkboxes, vol_boost, sp, smart_spd, allow_overlap, ns, nw]
for component in settings_inputs:
component.change(fn=save_config_to_file, inputs=settings_inputs, outputs=None)
if __name__ == "__main__":
demo.launch(
server_name="0.0.0.0",
theme=gr.themes.Soft(primary_hue="blue", secondary_hue="cyan"),
css=custom_css
)