Spaces:
Sleeping
Sleeping
Upload 2 files
Browse files- app.py +38 -90
- tts_engine.py +87 -145
app.py
CHANGED
|
@@ -1,39 +1,15 @@
|
|
| 1 |
-
import spaces
|
| 2 |
-
import os
|
| 3 |
os.environ['SPACES_ZERO_GPU'] = '1'
|
| 4 |
-
|
| 5 |
-
import gradio as gr
|
| 6 |
-
import soundfile as sf
|
| 7 |
-
import tempfile
|
| 8 |
-
import torch
|
| 9 |
-
import librosa
|
| 10 |
-
import time
|
| 11 |
-
import traceback
|
| 12 |
-
|
| 13 |
from tts_engine import VoiceEngine
|
| 14 |
|
| 15 |
-
# --- 1. SETUP MODEL ---
|
| 16 |
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 17 |
-
MY_BACKBONE = "ktvoice/Backbone"
|
| 18 |
-
MY_CODEC = "ktvoice/Codec"
|
| 19 |
-
|
| 20 |
try:
|
| 21 |
-
tts = VoiceEngine(
|
| 22 |
-
backbone_repo=MY_BACKBONE,
|
| 23 |
-
backbone_device=device,
|
| 24 |
-
codec_repo=MY_CODEC,
|
| 25 |
-
codec_device=device
|
| 26 |
-
)
|
| 27 |
except Exception as e:
|
| 28 |
-
print(f"
|
| 29 |
-
|
| 30 |
-
def encode_reference(self, path): return None
|
| 31 |
-
def infer(self, text, ref, ref_text):
|
| 32 |
-
time.sleep(1); import numpy as np
|
| 33 |
-
return np.random.uniform(-0.1, 0.1, 24000*2)
|
| 34 |
-
tts = MockTTS()
|
| 35 |
|
| 36 |
-
# --- 2. DATA ---
|
| 37 |
VOICE_SAMPLES = {
|
| 38 |
"Tuyên (nam miền Bắc)": {"audio": "./sample/Tuyên (nam miền Bắc).wav", "text": "./sample/Tuyên (nam miền Bắc).txt"},
|
| 39 |
"Thiện Tâm": {"audio": "./sample/thientam.mp3", "text": "./sample/thientam.txt"},
|
|
@@ -47,90 +23,62 @@ VOICE_SAMPLES = {
|
|
| 47 |
"Dung (nữ miền Nam)": {"audio": "./sample/Dung (nữ miền Nam).wav", "text": "./sample/Dung (nữ miền Nam).txt"}
|
| 48 |
}
|
| 49 |
|
| 50 |
-
def
|
| 51 |
if choice in VOICE_SAMPLES:
|
| 52 |
-
audio = VOICE_SAMPLES[choice]["audio"]
|
| 53 |
with open(VOICE_SAMPLES[choice]["text"], "r", encoding="utf-8") as f:
|
| 54 |
-
return audio, f.read()
|
| 55 |
return None, ""
|
| 56 |
|
| 57 |
@spaces.GPU(duration=120)
|
| 58 |
-
def
|
|
|
|
| 59 |
try:
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
p_text = text
|
| 63 |
-
if pause == "Trung bình": p_text = p_text.replace(",", ", , ").replace(".", ". . ")
|
| 64 |
-
elif pause == "Dài": p_text = p_text.replace(",", ", , , ").replace(".", ". . . . ")
|
| 65 |
-
|
| 66 |
-
ref_path, ref_txt = (c_audio, c_text) if mode == "custom_mode" else (VOICE_SAMPLES[voice]["audio"], open(VOICE_SAMPLES[voice]["text"], "r", encoding="utf-8").read())
|
| 67 |
-
|
| 68 |
start = time.time()
|
| 69 |
-
|
| 70 |
-
wav = tts.infer(
|
| 71 |
-
|
| 72 |
if speed != 1.0: wav = librosa.effects.time_stretch(wav, rate=float(speed))
|
| 73 |
-
|
| 74 |
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
|
| 75 |
sf.write(tmp.name, wav, 24000)
|
| 76 |
return tmp.name, f"✅ Hoàn tất ({time.time()-start:.2f}s)"
|
| 77 |
except Exception as e: return None, f"❌ Lỗi: {str(e)}"
|
| 78 |
|
| 79 |
-
# ---
|
| 80 |
-
theme = gr.themes.Default(
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
font=[gr.themes.GoogleFont('Inter'), 'sans-serif']
|
| 84 |
-
).set(
|
| 85 |
-
body_background_fill="#020617",
|
| 86 |
-
block_background_fill="#0f172a",
|
| 87 |
-
input_background_fill="#1e293b",
|
| 88 |
-
button_primary_background_fill="linear-gradient(135deg, #6366f1 0%, #a855f7 100%)",
|
| 89 |
)
|
| 90 |
|
| 91 |
-
css = ""
|
| 92 |
-
.main-wrap { max-width: 1240px !important; margin: auto !important; padding: 20px !important; }
|
| 93 |
-
.st-card { border-radius: 12px !important; border: 1px solid rgba(255,255,255,0.1) !important; padding: 20px !important; background: #0f172a !important; }
|
| 94 |
-
.footer { text-align: center; margin-top: 50px; color: #475569; font-size: 0.8rem; }
|
| 95 |
-
* { font-family: 'Inter', sans-serif !important; }
|
| 96 |
-
"""
|
| 97 |
|
| 98 |
-
with gr.Blocks(title="AI
|
| 99 |
with gr.Column(elem_classes="main-wrap"):
|
| 100 |
-
with gr.Row(
|
| 101 |
with gr.Column(scale=1):
|
| 102 |
with gr.Group(elem_classes="st-card"):
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
with gr.Column(scale=1):
|
| 107 |
-
with gr.Tabs() as
|
| 108 |
-
with gr.TabItem("👤 Giọng Nghệ Sĩ", id="
|
| 109 |
-
|
| 110 |
-
with gr.Accordion("Nghe thử
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
c_text = gr.Textbox(label="Nội dung lời thoại", lines=5)
|
| 115 |
-
|
| 116 |
with gr.Row():
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
btn = gr.Button("TỔNG HỢP GIỌNG NÓI", variant="primary", size="lg")
|
| 122 |
-
|
| 123 |
with gr.Group(elem_classes="st-card"):
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
gr.HTML("<div class='footer'>ENGINE BY KTVOICE • 2025</div>")
|
| 127 |
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
btn.click(synthesize_speech, [text_input, v_select, c_audio, c_text, cur_mode, p_lvl, s_val], [a_out, s_out])
|
| 133 |
|
| 134 |
if __name__ == "__main__":
|
| 135 |
-
# Di chuyển theme và css vào launch() để đúng chuẩn Gradio 6.0
|
| 136 |
demo.queue().launch(theme=theme, css=css, server_name="0.0.0.0", server_port=7860)
|
|
|
|
| 1 |
+
import spaces, os, gradio as gr, soundfile as sf, tempfile, torch, librosa, time
|
|
|
|
| 2 |
os.environ['SPACES_ZERO_GPU'] = '1'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
from tts_engine import VoiceEngine
|
| 4 |
|
| 5 |
+
# --- 1. SETUP MODEL (GIỮ LOGIC TỰ ĐỘNG CỦA TÁC GIẢ) ---
|
| 6 |
device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
|
|
|
|
|
|
|
|
| 7 |
try:
|
| 8 |
+
tts = VoiceEngine(backbone_repo="ktvoice/Backbone", backbone_device=device, codec_repo="ktvoice/Codec", codec_device=device)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
except Exception as e:
|
| 10 |
+
print(f"⚠️ Lỗi: {e}")
|
| 11 |
+
tts = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
|
|
|
| 13 |
VOICE_SAMPLES = {
|
| 14 |
"Tuyên (nam miền Bắc)": {"audio": "./sample/Tuyên (nam miền Bắc).wav", "text": "./sample/Tuyên (nam miền Bắc).txt"},
|
| 15 |
"Thiện Tâm": {"audio": "./sample/thientam.mp3", "text": "./sample/thientam.txt"},
|
|
|
|
| 23 |
"Dung (nữ miền Nam)": {"audio": "./sample/Dung (nữ miền Nam).wav", "text": "./sample/Dung (nữ miền Nam).txt"}
|
| 24 |
}
|
| 25 |
|
| 26 |
+
def load_ref(choice):
|
| 27 |
if choice in VOICE_SAMPLES:
|
|
|
|
| 28 |
with open(VOICE_SAMPLES[choice]["text"], "r", encoding="utf-8") as f:
|
| 29 |
+
return VOICE_SAMPLES[choice]["audio"], f.read()
|
| 30 |
return None, ""
|
| 31 |
|
| 32 |
@spaces.GPU(duration=120)
|
| 33 |
+
def process_tts(text, voice, c_audio, c_text, mode, pause, speed):
|
| 34 |
+
if not tts: return None, "❌ Lỗi khởi tạo mô hình!"
|
| 35 |
try:
|
| 36 |
+
ref_path, ref_txt = (c_audio, c_text) if mode == "custom" else (VOICE_SAMPLES[voice]["audio"], open(VOICE_SAMPLES[voice]["text"], "r", encoding="utf-8").read())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
start = time.time()
|
| 38 |
+
codes = tts.encode_reference(ref_path)
|
| 39 |
+
wav = tts.infer(text[:400], codes, ref_txt)
|
|
|
|
| 40 |
if speed != 1.0: wav = librosa.effects.time_stretch(wav, rate=float(speed))
|
|
|
|
| 41 |
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
|
| 42 |
sf.write(tmp.name, wav, 24000)
|
| 43 |
return tmp.name, f"✅ Hoàn tất ({time.time()-start:.2f}s)"
|
| 44 |
except Exception as e: return None, f"❌ Lỗi: {str(e)}"
|
| 45 |
|
| 46 |
+
# --- UI ---
|
| 47 |
+
theme = gr.themes.Default(primary_hue="indigo", neutral_hue="slate", font=[gr.themes.GoogleFont('Inter'), 'sans-serif']).set(
|
| 48 |
+
body_background_fill="#020617", block_background_fill="#0f172a",
|
| 49 |
+
input_background_fill="#1e293b", button_primary_background_fill="linear-gradient(135deg, #6366f1 0%, #a855f7 100%)",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
)
|
| 51 |
|
| 52 |
+
css = ".main-wrap { max-width: 1280px !important; margin: auto !important; padding: 20px !important; } .st-card { border-radius: 12px !important; border: 1px solid rgba(255,255,255,0.1) !important; padding: 25px !important; background: #0f172a !important; } * { font-family: 'Inter', sans-serif !important; } label span { font-weight: 700 !important; color: #818cf8 !important; text-transform: uppercase; font-size: 0.75rem !important; } .footer { text-align: center; margin-top: 50px; color: #475569; font-size: 0.8rem; }"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
+
with gr.Blocks(title="AI Studio") as demo:
|
| 55 |
with gr.Column(elem_classes="main-wrap"):
|
| 56 |
+
with gr.Row():
|
| 57 |
with gr.Column(scale=1):
|
| 58 |
with gr.Group(elem_classes="st-card"):
|
| 59 |
+
txt = gr.Textbox(label="VĂN BẢN ĐẦU VÀO", lines=20, placeholder="Nhập nội dung...")
|
| 60 |
+
gr.HTML("<div style='text-align: right; color: #6366f1; font-weight: 700;'>0 / 250</div>")
|
|
|
|
| 61 |
with gr.Column(scale=1):
|
| 62 |
+
with gr.Tabs() as ts:
|
| 63 |
+
with gr.TabItem("👤 Giọng Nghệ Sĩ", id="preset"):
|
| 64 |
+
v_sel = gr.Dropdown(choices=list(VOICE_SAMPLES.keys()), value="Tuyên (nam miền Bắc)", label="Chọn nghệ sĩ")
|
| 65 |
+
with gr.Accordion("Nghe thử", open=False): rp, rt = gr.Audio(interactive=False), gr.Markdown()
|
| 66 |
+
with gr.TabItem("🎙️ Tự Nhân Bản", id="custom"):
|
| 67 |
+
ca = gr.Audio(label="Audio gốc", type="filepath")
|
| 68 |
+
ct = gr.Textbox(label="Nội dung audio mẫu", lines=5)
|
|
|
|
|
|
|
| 69 |
with gr.Row():
|
| 70 |
+
pl = gr.Radio(choices=["Mặc định", "Trung bình", "Dài"], value="Mặc định", label="Ngắt nghỉ")
|
| 71 |
+
sv = gr.Dropdown(choices=[0.8, 0.9, 1.0, 1.1, 1.2, 1.5], value=1.0, label="Tốc độ")
|
| 72 |
+
md = gr.State("preset")
|
| 73 |
+
btn = gr.Button("TẠO GIỌNG NÓI NGAY", variant="primary", size="lg")
|
|
|
|
|
|
|
| 74 |
with gr.Group(elem_classes="st-card"):
|
| 75 |
+
ao, st = gr.Audio(label="KẾT QUẢ", interactive=False, autoplay=True), gr.Markdown("<p style='text-align: center; color: #6366f1;'>Sẵn sàng</p>")
|
| 76 |
+
gr.HTML("<div class='footer'>AI VOICE ENGINE • PROFESSIONAL STUDIO 2025</div>")
|
|
|
|
| 77 |
|
| 78 |
+
v_sel.change(load_ref, v_sel, [rp, rt])
|
| 79 |
+
ts.children[0].select(lambda: "preset", None, md)
|
| 80 |
+
ts.children[1].select(lambda: "custom", None, md)
|
| 81 |
+
btn.click(process_tts, [txt, v_sel, ca, ct, md, pl, sv], [ao, st])
|
|
|
|
| 82 |
|
| 83 |
if __name__ == "__main__":
|
|
|
|
| 84 |
demo.queue().launch(theme=theme, css=css, server_name="0.0.0.0", server_port=7860)
|
tts_engine.py
CHANGED
|
@@ -1,190 +1,132 @@
|
|
| 1 |
-
|
| 2 |
-
|
|
|
|
|
|
|
| 3 |
import librosa
|
| 4 |
import numpy as np
|
| 5 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
from neucodec import NeuCodec, DistillNeuCodec
|
| 7 |
from transformers import AutoTokenizer, AutoModelForCausalLM
|
| 8 |
from utils.phonemize_text import phonemize_text, phonemize_with_dict
|
| 9 |
-
import re
|
| 10 |
|
| 11 |
def _linear_overlap_add(frames: list[np.ndarray], stride: int) -> np.ndarray:
|
| 12 |
assert len(frames)
|
| 13 |
dtype = frames[0].dtype
|
| 14 |
shape = frames[0].shape[:-1]
|
| 15 |
-
|
| 16 |
-
total_size = 0
|
| 17 |
-
for i, frame in enumerate(frames):
|
| 18 |
-
frame_end = stride * i + frame.shape[-1]
|
| 19 |
-
total_size = max(total_size, frame_end)
|
| 20 |
-
|
| 21 |
sum_weight = np.zeros(total_size, dtype=dtype)
|
| 22 |
out = np.zeros(*shape, total_size, dtype=dtype)
|
| 23 |
-
|
| 24 |
offset: int = 0
|
| 25 |
for frame in frames:
|
| 26 |
frame_length = frame.shape[-1]
|
| 27 |
t = np.linspace(0, 1, frame_length + 2, dtype=dtype)[1:-1]
|
| 28 |
weight = np.abs(0.5 - (t - 0.5))
|
| 29 |
-
|
| 30 |
out[..., offset : offset + frame_length] += weight * frame
|
| 31 |
sum_weight[offset : offset + frame_length] += weight
|
| 32 |
offset += stride
|
| 33 |
-
assert sum_weight.min() > 0
|
| 34 |
return out / sum_weight
|
| 35 |
|
| 36 |
class VoiceEngine:
|
| 37 |
-
def __init__(
|
| 38 |
-
self,
|
| 39 |
-
backbone_repo="ktvoice/Backbone", # Thiết lập mặc định về repo của bạn
|
| 40 |
-
backbone_device="cpu",
|
| 41 |
-
codec_repo="ktvoice/Codec", # Thiết lập mặc định về repo của bạn
|
| 42 |
-
codec_device="cpu",
|
| 43 |
-
):
|
| 44 |
-
|
| 45 |
-
# Constants
|
| 46 |
self.sample_rate = 24_000
|
| 47 |
self.max_context = 2048
|
| 48 |
self.hop_length = 480
|
| 49 |
-
self.streaming_overlap_frames = 1
|
| 50 |
-
self.streaming_frames_per_chunk = 25
|
| 51 |
-
self.streaming_lookforward = 5
|
| 52 |
-
self.streaming_lookback = 50
|
| 53 |
-
self.streaming_stride_samples = self.streaming_frames_per_chunk * self.hop_length
|
| 54 |
-
|
| 55 |
-
# Flags
|
| 56 |
self._is_quantized_model = False
|
| 57 |
self._is_onnx_codec = False
|
| 58 |
-
|
| 59 |
self.tokenizer = None
|
| 60 |
-
|
| 61 |
-
# Khởi tạo mô hình
|
| 62 |
self._load_backbone(backbone_repo, backbone_device)
|
| 63 |
self._load_codec(codec_repo, codec_device)
|
| 64 |
|
| 65 |
-
def _load_backbone(self,
|
| 66 |
-
print(f"Loading backbone from: {
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
from llama_cpp import Llama
|
| 71 |
-
except ImportError as e:
|
| 72 |
-
raise ImportError("Vui lòng cài đặt llama-cpp-python để dùng model GGUF.") from e
|
| 73 |
-
self.backbone = Llama.from_pretrained(
|
| 74 |
-
repo_id=backbone_repo,
|
| 75 |
-
filename="*.gguf",
|
| 76 |
-
verbose=False,
|
| 77 |
-
n_gpu_layers=-1 if backbone_device == "gpu" else 0,
|
| 78 |
-
n_ctx=self.max_context,
|
| 79 |
-
mlock=True,
|
| 80 |
-
flash_attn=True if backbone_device == "gpu" else False,
|
| 81 |
-
)
|
| 82 |
self._is_quantized_model = True
|
| 83 |
else:
|
| 84 |
-
self.tokenizer = AutoTokenizer.from_pretrained(
|
| 85 |
-
self.backbone = AutoModelForCausalLM.from_pretrained(
|
| 86 |
-
torch.device(backbone_device)
|
| 87 |
-
)
|
| 88 |
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
# Tải model về thư mục tạm để vượt qua lỗi check tên repo của thư viện neucodec
|
| 95 |
-
local_codec_path = snapshot_download(repo_id=codec_repo)
|
| 96 |
-
|
| 97 |
-
codec_repo_lower = codec_repo.lower()
|
| 98 |
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
| 108 |
else:
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
if not self._is_onnx_codec:
|
| 113 |
-
self.codec.eval().to(codec_device)
|
| 114 |
-
|
| 115 |
-
|
| 116 |
|
| 117 |
-
def
|
| 118 |
-
|
| 119 |
-
output_str = self._infer_ggml(ref_codes, ref_text, text)
|
| 120 |
-
else:
|
| 121 |
-
prompt_ids = self._apply_chat_template(ref_codes, ref_text, text)
|
| 122 |
-
output_str = self._infer_torch(prompt_ids)
|
| 123 |
-
|
| 124 |
-
wav = self._decode(output_str)
|
| 125 |
-
return wav
|
| 126 |
-
|
| 127 |
-
def encode_reference(self, ref_audio_path: str | Path):
|
| 128 |
-
wav, _ = librosa.load(ref_audio_path, sr=16000, mono=True)
|
| 129 |
wav_tensor = torch.from_numpy(wav).float().unsqueeze(0).unsqueeze(0)
|
| 130 |
with torch.no_grad():
|
| 131 |
-
|
| 132 |
-
return ref_codes
|
| 133 |
|
| 134 |
-
def
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
recon = self.codec.decode_code(codes_np)
|
| 142 |
-
else:
|
| 143 |
-
with torch.no_grad():
|
| 144 |
-
codes_tensor = torch.tensor(speech_ids, dtype=torch.long)[None, None, :].to(self.codec.device)
|
| 145 |
-
recon = self.codec.decode_code(codes_tensor).cpu().numpy()
|
| 146 |
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
speech_replace = self.tokenizer.convert_tokens_to_ids("<|SPEECH_REPLACE|>")
|
| 153 |
-
speech_gen_start = self.tokenizer.convert_tokens_to_ids("<|SPEECH_GENERATION_START|>")
|
| 154 |
-
text_replace = self.tokenizer.convert_tokens_to_ids("<|TEXT_REPLACE|>")
|
| 155 |
-
text_prompt_start = self.tokenizer.convert_tokens_to_ids("<|TEXT_PROMPT_START|>")
|
| 156 |
-
text_prompt_end = self.tokenizer.convert_tokens_to_ids("<|TEXT_PROMPT_END|>")
|
| 157 |
-
|
| 158 |
-
input_ids = self.tokenizer.encode(input_text, add_special_tokens=False)
|
| 159 |
-
chat = "user: Convert the text to speech:<|TEXT_REPLACE|>\nassistant:<|SPEECH_REPLACE|>"
|
| 160 |
-
ids = self.tokenizer.encode(chat)
|
| 161 |
-
|
| 162 |
-
text_replace_idx = ids.index(text_replace)
|
| 163 |
-
ids = ids[:text_replace_idx] + [text_prompt_start] + input_ids + [text_prompt_end] + ids[text_replace_idx + 1 :]
|
| 164 |
-
|
| 165 |
-
speech_replace_idx = ids.index(speech_replace)
|
| 166 |
-
codes_str = "".join([f"<|speech_{i}|>" for i in ref_codes])
|
| 167 |
-
codes = self.tokenizer.encode(codes_str, add_special_tokens=False)
|
| 168 |
-
ids = ids[:speech_replace_idx] + [speech_gen_start] + list(codes)
|
| 169 |
-
|
| 170 |
-
return ids
|
| 171 |
|
| 172 |
-
def
|
| 173 |
-
|
| 174 |
-
speech_end_id = self.tokenizer.convert_tokens_to_ids("<|SPEECH_GENERATION_END|>")
|
| 175 |
with torch.no_grad():
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
min_new_tokens=50,
|
| 185 |
-
)
|
| 186 |
-
input_length = prompt_tensor.shape[-1]
|
| 187 |
-
output_str = self.tokenizer.decode(
|
| 188 |
-
output_tokens[0, input_length:].cpu().numpy().tolist(), add_special_tokens=False
|
| 189 |
-
)
|
| 190 |
-
return output_str
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import re
|
| 3 |
+
import time
|
| 4 |
+
import torch
|
| 5 |
import librosa
|
| 6 |
import numpy as np
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Generator
|
| 9 |
+
from huggingface_hub import snapshot_download
|
| 10 |
+
|
| 11 |
+
# --- BẢN VÁ (PATCH) ĐỂ CHẠY ĐƯỢC VỚI REPO CÁ NHÂN KTVOICE ---
|
| 12 |
+
import neucodec.model
|
| 13 |
+
import json
|
| 14 |
+
|
| 15 |
+
# Lưu lại hàm gốc của thư viện neucodec
|
| 16 |
+
_orig_from_pretrained = neucodec.model.NeuCodec._from_pretrained
|
| 17 |
+
|
| 18 |
+
@classmethod
|
| 19 |
+
def _patched_from_pretrained(cls, model_id, *args, **kwargs):
|
| 20 |
+
"""
|
| 21 |
+
Bản vá này giúp vượt qua lệnh assert model_id in [...] của thư viện neucodec.
|
| 22 |
+
Nó cho phép nạp mô hình từ bất kỳ repo nào (như ktvoice/Codec).
|
| 23 |
+
"""
|
| 24 |
+
# Nếu model_id là một đường dẫn local hoặc repo cá nhân,
|
| 25 |
+
# chúng ta "đánh lừa" thư viện bằng cách dùng tên repo chính thức để qua cửa assert.
|
| 26 |
+
valid_ids = ["neuphonic/neucodec", "neuphonic/distill-neucodec"]
|
| 27 |
+
check_id = model_id
|
| 28 |
+
if model_id not in valid_ids:
|
| 29 |
+
check_id = "neuphonic/neucodec"
|
| 30 |
+
|
| 31 |
+
# Thực hiện nạp mô hình (Lệnh assert sẽ kiểm tra check_id thay vì model_id của bạn)
|
| 32 |
+
return _orig_from_pretrained(check_id, *args, **kwargs)
|
| 33 |
+
|
| 34 |
+
# Áp dụng bản vá vào cả hai lớp của thư viện neucodec
|
| 35 |
+
neucodec.model.NeuCodec._from_pretrained = _patched_from_pretrained
|
| 36 |
+
neucodec.model.DistillNeuCodec._from_pretrained = _patched_from_pretrained
|
| 37 |
+
# -----------------------------------------------------------
|
| 38 |
+
|
| 39 |
from neucodec import NeuCodec, DistillNeuCodec
|
| 40 |
from transformers import AutoTokenizer, AutoModelForCausalLM
|
| 41 |
from utils.phonemize_text import phonemize_text, phonemize_with_dict
|
|
|
|
| 42 |
|
| 43 |
def _linear_overlap_add(frames: list[np.ndarray], stride: int) -> np.ndarray:
|
| 44 |
assert len(frames)
|
| 45 |
dtype = frames[0].dtype
|
| 46 |
shape = frames[0].shape[:-1]
|
| 47 |
+
total_size = max(stride * i + frame.shape[-1] for i, frame in enumerate(frames))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
sum_weight = np.zeros(total_size, dtype=dtype)
|
| 49 |
out = np.zeros(*shape, total_size, dtype=dtype)
|
|
|
|
| 50 |
offset: int = 0
|
| 51 |
for frame in frames:
|
| 52 |
frame_length = frame.shape[-1]
|
| 53 |
t = np.linspace(0, 1, frame_length + 2, dtype=dtype)[1:-1]
|
| 54 |
weight = np.abs(0.5 - (t - 0.5))
|
|
|
|
| 55 |
out[..., offset : offset + frame_length] += weight * frame
|
| 56 |
sum_weight[offset : offset + frame_length] += weight
|
| 57 |
offset += stride
|
|
|
|
| 58 |
return out / sum_weight
|
| 59 |
|
| 60 |
class VoiceEngine:
|
| 61 |
+
def __init__(self, backbone_repo="ktvoice/Backbone", backbone_device="cpu", codec_repo="ktvoice/Codec", codec_device="cpu"):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
self.sample_rate = 24_000
|
| 63 |
self.max_context = 2048
|
| 64 |
self.hop_length = 480
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
self._is_quantized_model = False
|
| 66 |
self._is_onnx_codec = False
|
|
|
|
| 67 |
self.tokenizer = None
|
| 68 |
+
|
|
|
|
| 69 |
self._load_backbone(backbone_repo, backbone_device)
|
| 70 |
self._load_codec(codec_repo, codec_device)
|
| 71 |
|
| 72 |
+
def _load_backbone(self, repo, device):
|
| 73 |
+
print(f"Loading backbone from: {repo} on {device} ...")
|
| 74 |
+
if "gguf" in repo.lower():
|
| 75 |
+
from llama_cpp import Llama
|
| 76 |
+
self.backbone = Llama.from_pretrained(repo_id=repo, filename="*.gguf", n_ctx=self.max_context)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
self._is_quantized_model = True
|
| 78 |
else:
|
| 79 |
+
self.tokenizer = AutoTokenizer.from_pretrained(repo)
|
| 80 |
+
self.backbone = AutoModelForCausalLM.from_pretrained(repo).to(torch.device(device))
|
|
|
|
|
|
|
| 81 |
|
| 82 |
+
def _load_codec(self, repo, device):
|
| 83 |
+
print(f"Loading codec from: {repo} on {device} ...")
|
| 84 |
+
# Tải mô hình về thư mục tạm
|
| 85 |
+
local_dir = snapshot_download(repo_id=repo)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
+
# GIẢI THÍCH: Tại sao cần tạo config.json giả?
|
| 88 |
+
# Thư viện neucodec mặc định tìm config.json khi repo name không phải là 'neuphonic/neucodec'.
|
| 89 |
+
# Chúng ta tạo một file config.json tối giản trong thư mục snapshot để đánh lừa nó.
|
| 90 |
+
config_path = os.path.join(local_dir, "config.json")
|
| 91 |
+
if not os.path.exists(config_path):
|
| 92 |
+
with open(config_path, "w") as f:
|
| 93 |
+
json.dump({"model_type": "neucodec"}, f)
|
| 94 |
+
|
| 95 |
+
# Nạp mô hình từ đường dẫn cục bộ
|
| 96 |
+
if "distill" in repo.lower():
|
| 97 |
+
self.codec = DistillNeuCodec.from_pretrained(local_dir)
|
| 98 |
else:
|
| 99 |
+
self.codec = NeuCodec.from_pretrained(local_dir)
|
| 100 |
+
self.codec.eval().to(device)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
|
| 102 |
+
def encode_reference(self, path):
|
| 103 |
+
wav, _ = librosa.load(path, sr=16000, mono=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
wav_tensor = torch.from_numpy(wav).float().unsqueeze(0).unsqueeze(0)
|
| 105 |
with torch.no_grad():
|
| 106 |
+
return self.codec.encode_code(audio_or_path=wav_tensor).squeeze(0).squeeze(0)
|
|
|
|
| 107 |
|
| 108 |
+
def infer(self, text, ref_codes, ref_text):
|
| 109 |
+
if self._is_quantized_model:
|
| 110 |
+
# Placeholder cho logic GGUF nếu bạn cần
|
| 111 |
+
return np.zeros(48000)
|
| 112 |
|
| 113 |
+
prompt_ids = self._apply_chat_template(ref_codes, ref_text, text)
|
| 114 |
+
prompt_tensor = torch.tensor(prompt_ids).unsqueeze(0).to(self.backbone.device)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
+
with torch.no_grad():
|
| 117 |
+
out = self.backbone.generate(prompt_tensor, max_length=self.max_context, do_sample=True, temperature=1)
|
| 118 |
+
|
| 119 |
+
tokens = self.tokenizer.decode(out[0, prompt_tensor.shape[-1]:], add_special_tokens=False)
|
| 120 |
+
return self._decode(tokens)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
+
def _decode(self, codes_str):
|
| 123 |
+
speech_ids = [int(n) for n in re.findall(r"<\|speech_(\d+)\|>", codes_str)]
|
|
|
|
| 124 |
with torch.no_grad():
|
| 125 |
+
codes_tensor = torch.tensor(speech_ids, dtype=torch.long)[None, None, :].to(self.codec.device)
|
| 126 |
+
return self.codec.decode_code(codes_tensor).cpu().numpy()[0, 0, :]
|
| 127 |
+
|
| 128 |
+
def _apply_chat_template(self, ref_codes, ref_text, text):
|
| 129 |
+
input_text = phonemize_with_dict(ref_text) + " " + phonemize_with_dict(text)
|
| 130 |
+
chat = f"user: Convert the text to speech:<|TEXT_PROMPT_START|>{input_text}<|TEXT_PROMPT_END|>\nassistant:<|SPEECH_GENERATION_START|>"
|
| 131 |
+
c_str = "".join([f"<|speech_{i}|>" for i in ref_codes])
|
| 132 |
+
return self.tokenizer.encode(chat + c_str)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|