ktvoice commited on
Commit
3f903dd
·
verified ·
1 Parent(s): a6d6c8b

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +4 -4
  2. tts_engine.py +346 -0
app.py CHANGED
@@ -7,18 +7,18 @@ import soundfile as sf
7
  import tempfile
8
  import torch
9
  import librosa
10
- from vieneu_tts import VieNeuTTS
11
  import time
12
 
13
  # --- 1. SETUP MODEL (Sử dụng repo cá nhân của bạn) ---
14
  device = "cuda" if torch.cuda.is_available() else "cpu"
15
 
16
  # THAY THẾ 'YOUR_USERNAME' bằng tên Hugging Face của bạn
17
- MY_BACKBONE_REPO = "YOUR_USERNAME/my-vieneu-tts"
18
- MY_CODEC_REPO = "YOUR_USERNAME/my-neucodec"
19
 
20
  try:
21
- tts = VieNeuTTS(
22
  backbone_repo=MY_BACKBONE_REPO,
23
  backbone_device=device,
24
  codec_repo=MY_CODEC_REPO,
 
7
  import tempfile
8
  import torch
9
  import librosa
10
+ from tts_engine import VoiceEngine
11
  import time
12
 
13
  # --- 1. SETUP MODEL (Sử dụng repo cá nhân của bạn) ---
14
  device = "cuda" if torch.cuda.is_available() else "cpu"
15
 
16
  # THAY THẾ 'YOUR_USERNAME' bằng tên Hugging Face của bạn
17
+ MY_BACKBONE_REPO = "ktvoice/Backbone"
18
+ MY_CODEC_REPO = "ktvoice/Codec"
19
 
20
  try:
21
+ tts = VoiceEngine(
22
  backbone_repo=MY_BACKBONE_REPO,
23
  backbone_device=device,
24
  codec_repo=MY_CODEC_REPO,
tts_engine.py ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ from typing import Generator
3
+ import librosa
4
+ import numpy as np
5
+ import torch
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="pnnbao-ump/VieNeu-TTS",
40
+ backbone_device="cpu",
41
+ codec_repo="neuphonic/neucodec",
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
+ # ggml & onnx flags
56
+ self._is_quantized_model = False
57
+ self._is_onnx_codec = False
58
+
59
+ # HF tokenizer
60
+ self.tokenizer = None
61
+
62
+ # Load models
63
+ self._load_backbone(backbone_repo, backbone_device)
64
+ self._load_codec(codec_repo, codec_device)
65
+
66
+ def _load_backbone(self, backbone_repo, backbone_device):
67
+ print(f"Loading backbone from: {backbone_repo} on {backbone_device} ...")
68
+
69
+ if backbone_repo.lower().endswith("gguf") or "gguf" in backbone_repo.lower():
70
+ try:
71
+ from llama_cpp import Llama
72
+ except ImportError as e:
73
+ raise ImportError(
74
+ "Failed to import `llama_cpp`. "
75
+ "Please install it with:\n"
76
+ " pip install llama-cpp-python"
77
+ ) from e
78
+ self.backbone = Llama.from_pretrained(
79
+ repo_id=backbone_repo,
80
+ filename="*.gguf",
81
+ verbose=False,
82
+ n_gpu_layers=-1 if backbone_device == "gpu" else 0,
83
+ n_ctx=self.max_context,
84
+ mlock=True,
85
+ flash_attn=True if backbone_device == "gpu" else False,
86
+ )
87
+ self._is_quantized_model = True
88
+
89
+ else:
90
+ self.tokenizer = AutoTokenizer.from_pretrained(backbone_repo)
91
+ self.backbone = AutoModelForCausalLM.from_pretrained(backbone_repo).to(
92
+ torch.device(backbone_device)
93
+ )
94
+
95
+ def _load_codec(self, codec_repo, codec_device):
96
+ print(f"Loading codec from: {codec_repo} on {codec_device} ...")
97
+ match codec_repo:
98
+ case "neuphonic/neucodec":
99
+ self.codec = NeuCodec.from_pretrained(codec_repo)
100
+ self.codec.eval().to(codec_device)
101
+ case "neuphonic/distill-neucodec":
102
+ self.codec = DistillNeuCodec.from_pretrained(codec_repo)
103
+ self.codec.eval().to(codec_device)
104
+ case "neuphonic/neucodec-onnx-decoder":
105
+ if codec_device != "cpu":
106
+ raise ValueError("Onnx decoder only currently runs on CPU.")
107
+ try:
108
+ from neucodec import NeuCodecOnnxDecoder
109
+ except ImportError as e:
110
+ raise ImportError(
111
+ "Failed to import the onnx decoder."
112
+ " Ensure you have onnxruntime installed as well as neucodec >= 0.0.4."
113
+ ) from e
114
+ self.codec = NeuCodecOnnxDecoder.from_pretrained(codec_repo)
115
+ self._is_onnx_codec = True
116
+ case _:
117
+ raise ValueError(f"Unsupported codec repository: {codec_repo}")
118
+
119
+ def infer(self, text: str, ref_codes: np.ndarray | torch.Tensor, ref_text: str) -> np.ndarray:
120
+ """
121
+ Perform inference to generate speech from text using the TTS model and reference audio.
122
+
123
+ Args:
124
+ text (str): Input text to be converted to speech.
125
+ ref_codes (np.ndarray | torch.tensor): Encoded reference.
126
+ ref_text (str): Reference text for reference audio. Defaults to None.
127
+ Returns:
128
+ np.ndarray: Generated speech waveform.
129
+ """
130
+
131
+ # Generate tokens
132
+ if self._is_quantized_model:
133
+ output_str = self._infer_ggml(ref_codes, ref_text, text)
134
+ else:
135
+ prompt_ids = self._apply_chat_template(ref_codes, ref_text, text)
136
+ output_str = self._infer_torch(prompt_ids)
137
+
138
+ # Decode
139
+ wav = self._decode(output_str)
140
+
141
+ return wav
142
+
143
+ def infer_stream(self, text: str, ref_codes: np.ndarray | torch.Tensor, ref_text: str) -> Generator[np.ndarray, None, None]:
144
+ """
145
+ Perform streaming inference to generate speech from text using the TTS model and reference audio.
146
+
147
+ Args:
148
+ text (str): Input text to be converted to speech.
149
+ ref_codes (np.ndarray | torch.tensor): Encoded reference.
150
+ ref_text (str): Reference text for reference audio. Defaults to None.
151
+ Yields:
152
+ np.ndarray: Generated speech waveform.
153
+ """
154
+
155
+ if self._is_quantized_model:
156
+ return self._infer_stream_ggml(ref_codes, ref_text, text)
157
+ else:
158
+ raise NotImplementedError("Streaming is not implemented for the torch backend!")
159
+
160
+ def encode_reference(self, ref_audio_path: str | Path):
161
+ wav, _ = librosa.load(ref_audio_path, sr=16000, mono=True)
162
+ wav_tensor = torch.from_numpy(wav).float().unsqueeze(0).unsqueeze(0) # [1, 1, T]
163
+ with torch.no_grad():
164
+ ref_codes = self.codec.encode_code(audio_or_path=wav_tensor).squeeze(0).squeeze(0)
165
+ return ref_codes
166
+
167
+ def _decode(self, codes: str):
168
+ """Decode speech tokens to audio waveform."""
169
+ # Extract speech token IDs using regex
170
+ speech_ids = [int(num) for num in re.findall(r"<\|speech_(\d+)\|>", codes)]
171
+
172
+ if len(speech_ids) == 0:
173
+ raise ValueError(
174
+ "No valid speech tokens found in the output. "
175
+ "The model may not have generated proper speech tokens."
176
+ )
177
+
178
+ # Onnx decode
179
+ if self._is_onnx_codec:
180
+ codes = np.array(speech_ids, dtype=np.int32)[np.newaxis, np.newaxis, :]
181
+ recon = self.codec.decode_code(codes)
182
+ # Torch decode
183
+ else:
184
+ with torch.no_grad():
185
+ codes = torch.tensor(speech_ids, dtype=torch.long)[None, None, :].to(
186
+ self.codec.device
187
+ )
188
+ recon = self.codec.decode_code(codes).cpu().numpy()
189
+
190
+ return recon[0, 0, :]
191
+
192
+ def _apply_chat_template(self, ref_codes: list[int], ref_text: str, input_text: str) -> list[int]:
193
+ input_text = phonemize_with_dict(ref_text) + " " + phonemize_with_dict(input_text)
194
+
195
+ speech_replace = self.tokenizer.convert_tokens_to_ids("<|SPEECH_REPLACE|>")
196
+ speech_gen_start = self.tokenizer.convert_tokens_to_ids("<|SPEECH_GENERATION_START|>")
197
+ text_replace = self.tokenizer.convert_tokens_to_ids("<|TEXT_REPLACE|>")
198
+ text_prompt_start = self.tokenizer.convert_tokens_to_ids("<|TEXT_PROMPT_START|>")
199
+ text_prompt_end = self.tokenizer.convert_tokens_to_ids("<|TEXT_PROMPT_END|>")
200
+
201
+ input_ids = self.tokenizer.encode(input_text, add_special_tokens=False)
202
+ chat = """user: Convert the text to speech:<|TEXT_REPLACE|>\nassistant:<|SPEECH_REPLACE|>"""
203
+ ids = self.tokenizer.encode(chat)
204
+
205
+ text_replace_idx = ids.index(text_replace)
206
+ ids = (
207
+ ids[:text_replace_idx]
208
+ + [text_prompt_start]
209
+ + input_ids
210
+ + [text_prompt_end]
211
+ + ids[text_replace_idx + 1 :] # noqa
212
+ )
213
+
214
+ speech_replace_idx = ids.index(speech_replace)
215
+ codes_str = "".join([f"<|speech_{i}|>" for i in ref_codes])
216
+ codes = self.tokenizer.encode(codes_str, add_special_tokens=False)
217
+ ids = ids[:speech_replace_idx] + [speech_gen_start] + list(codes)
218
+
219
+ return ids
220
+
221
+ def _infer_torch(self, prompt_ids: list[int]) -> str:
222
+ prompt_tensor = torch.tensor(prompt_ids).unsqueeze(0).to(self.backbone.device)
223
+ speech_end_id = self.tokenizer.convert_tokens_to_ids("<|SPEECH_GENERATION_END|>")
224
+ with torch.no_grad():
225
+ output_tokens = self.backbone.generate(
226
+ prompt_tensor,
227
+ max_length=self.max_context,
228
+ eos_token_id=speech_end_id,
229
+ do_sample=True,
230
+ temperature=1,
231
+ top_k=50,
232
+ use_cache=True,
233
+ min_new_tokens=50,
234
+ )
235
+ input_length = prompt_tensor.shape[-1]
236
+ output_str = self.tokenizer.decode(
237
+ output_tokens[0, input_length:].cpu().numpy().tolist(), add_special_tokens=False
238
+ )
239
+ return output_str
240
+
241
+ def _infer_ggml(self, ref_codes: list[int], ref_text: str, input_text: str) -> str:
242
+ ref_text = phonemize_with_dict(ref_text)
243
+ input_text = phonemize_with_dict(input_text)
244
+
245
+ codes_str = "".join([f"<|speech_{idx}|>" for idx in ref_codes])
246
+ prompt = (
247
+ f"user: Convert the text to speech:<|TEXT_PROMPT_START|>{ref_text} {input_text}"
248
+ f"<|TEXT_PROMPT_END|>\nassistant:<|SPEECH_GENERATION_START|>{codes_str}"
249
+ )
250
+ output = self.backbone(
251
+ prompt,
252
+ max_tokens=self.max_context,
253
+ temperature=1.0,
254
+ top_k=50,
255
+ stop=["<|SPEECH_GENERATION_END|>"],
256
+ )
257
+ output_str = output["choices"][0]["text"]
258
+ return output_str
259
+
260
+ def _infer_stream_ggml(self, ref_codes: torch.Tensor, ref_text: str, input_text: str) -> Generator[np.ndarray, None, None]:
261
+ ref_text = phonemize_with_dict(ref_text)
262
+ input_text = phonemize_with_dict(input_text)
263
+
264
+ codes_str = "".join([f"<|speech_{idx}|>" for idx in ref_codes])
265
+ prompt = (
266
+ f"user: Convert the text to speech:<|TEXT_PROMPT_START|>{ref_text} {input_text}"
267
+ f"<|TEXT_PROMPT_END|>\nassistant:<|SPEECH_GENERATION_START|>{codes_str}"
268
+ )
269
+
270
+ audio_cache: list[np.ndarray] = []
271
+ token_cache: list[str] = [f"<|speech_{idx}|>" for idx in ref_codes]
272
+ n_decoded_samples: int = 0
273
+ n_decoded_tokens: int = len(ref_codes)
274
+
275
+ for item in self.backbone(
276
+ prompt,
277
+ max_tokens=self.max_context,
278
+ temperature=0.2,
279
+ top_k=50,
280
+ stop=["<|SPEECH_GENERATION_END|>"],
281
+ stream=True
282
+ ):
283
+ output_str = item["choices"][0]["text"]
284
+ token_cache.append(output_str)
285
+
286
+ if len(token_cache[n_decoded_tokens:]) >= self.streaming_frames_per_chunk + self.streaming_lookforward:
287
+
288
+ # decode chunk
289
+ tokens_start = max(
290
+ n_decoded_tokens
291
+ - self.streaming_lookback
292
+ - self.streaming_overlap_frames,
293
+ 0
294
+ )
295
+ tokens_end = (
296
+ n_decoded_tokens
297
+ + self.streaming_frames_per_chunk
298
+ + self.streaming_lookforward
299
+ + self.streaming_overlap_frames
300
+ )
301
+ sample_start = (
302
+ n_decoded_tokens - tokens_start
303
+ ) * self.hop_length
304
+ sample_end = (
305
+ sample_start
306
+ + (self.streaming_frames_per_chunk + 2 * self.streaming_overlap_frames) * self.hop_length
307
+ )
308
+ curr_codes = token_cache[tokens_start:tokens_end]
309
+ recon = self._decode("".join(curr_codes))
310
+ recon = recon[sample_start:sample_end]
311
+ audio_cache.append(recon)
312
+
313
+ # postprocess
314
+ processed_recon = _linear_overlap_add(
315
+ audio_cache, stride=self.streaming_stride_samples
316
+ )
317
+ new_samples_end = len(audio_cache) * self.streaming_stride_samples
318
+ processed_recon = processed_recon[
319
+ n_decoded_samples:new_samples_end
320
+ ]
321
+ n_decoded_samples = new_samples_end
322
+ n_decoded_tokens += self.streaming_frames_per_chunk
323
+ yield processed_recon
324
+
325
+ # final decoding handled separately as non-constant chunk size
326
+ remaining_tokens = len(token_cache) - n_decoded_tokens
327
+ if len(token_cache) > n_decoded_tokens:
328
+ tokens_start = max(
329
+ len(token_cache)
330
+ - (self.streaming_lookback + self.streaming_overlap_frames + remaining_tokens),
331
+ 0
332
+ )
333
+ sample_start = (
334
+ len(token_cache)
335
+ - tokens_start
336
+ - remaining_tokens
337
+ - self.streaming_overlap_frames
338
+ ) * self.hop_length
339
+ curr_codes = token_cache[tokens_start:]
340
+ recon = self._decode("".join(curr_codes))
341
+ recon = recon[sample_start:]
342
+ audio_cache.append(recon)
343
+
344
+ processed_recon = _linear_overlap_add(audio_cache, stride=self.streaming_stride_samples)
345
+ processed_recon = processed_recon[n_decoded_samples:]
346
+ yield processed_recon