Spaces:
Running
on
Zero
Running
on
Zero
Upload 2 files
Browse files- examples/infer_long_text.py +223 -0
- examples/sample_long_text.txt +1 -0
examples/infer_long_text.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import os
|
| 3 |
+
import re
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import List
|
| 7 |
+
import numpy as np
|
| 8 |
+
import soundfile as sf
|
| 9 |
+
import torch
|
| 10 |
+
from vieneu_tts import VieNeuTTS
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def split_text_into_chunks(text: str, max_chars: int = 256) -> List[str]:
|
| 14 |
+
"""
|
| 15 |
+
Split raw text into chunks no longer than max_chars.
|
| 16 |
+
Preference is given to sentence boundaries; otherwise falls back to word-based splitting.
|
| 17 |
+
"""
|
| 18 |
+
sentences = re.split(r"(?<=[\.\!\?\…])\s+", text.strip())
|
| 19 |
+
chunks: List[str] = []
|
| 20 |
+
buffer = ""
|
| 21 |
+
|
| 22 |
+
def flush_buffer():
|
| 23 |
+
nonlocal buffer
|
| 24 |
+
if buffer:
|
| 25 |
+
chunks.append(buffer.strip())
|
| 26 |
+
buffer = ""
|
| 27 |
+
|
| 28 |
+
for sentence in sentences:
|
| 29 |
+
sentence = sentence.strip()
|
| 30 |
+
if not sentence:
|
| 31 |
+
continue
|
| 32 |
+
|
| 33 |
+
# If single sentence already fits, try to append to current buffer
|
| 34 |
+
if len(sentence) <= max_chars:
|
| 35 |
+
candidate = f"{buffer} {sentence}".strip() if buffer else sentence
|
| 36 |
+
if len(candidate) <= max_chars:
|
| 37 |
+
buffer = candidate
|
| 38 |
+
else:
|
| 39 |
+
flush_buffer()
|
| 40 |
+
buffer = sentence
|
| 41 |
+
continue
|
| 42 |
+
|
| 43 |
+
# Fallback: sentence too long, break by words
|
| 44 |
+
flush_buffer()
|
| 45 |
+
words = sentence.split()
|
| 46 |
+
current = ""
|
| 47 |
+
for word in words:
|
| 48 |
+
candidate = f"{current} {word}".strip() if current else word
|
| 49 |
+
if len(candidate) > max_chars and current:
|
| 50 |
+
chunks.append(current.strip())
|
| 51 |
+
current = word
|
| 52 |
+
else:
|
| 53 |
+
current = candidate
|
| 54 |
+
if current:
|
| 55 |
+
chunks.append(current.strip())
|
| 56 |
+
|
| 57 |
+
flush_buffer()
|
| 58 |
+
return [chunk for chunk in chunks if chunk]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def infer_long_text(
|
| 62 |
+
text: str,
|
| 63 |
+
ref_audio_path: str,
|
| 64 |
+
ref_text_path: str,
|
| 65 |
+
output_path: str,
|
| 66 |
+
chunk_dir: str | None = None,
|
| 67 |
+
max_chars: int = 256,
|
| 68 |
+
backbone_repo: str = "pnnbao-ump/VieNeu-TTS",
|
| 69 |
+
codec_repo: str = "neuphonic/neucodec",
|
| 70 |
+
device: str | None = None,
|
| 71 |
+
) -> str:
|
| 72 |
+
"""
|
| 73 |
+
Generate speech for long-form text by chunking into manageable segments.
|
| 74 |
+
|
| 75 |
+
Returns:
|
| 76 |
+
The path to the combined audio file.
|
| 77 |
+
"""
|
| 78 |
+
|
| 79 |
+
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
| 80 |
+
if device not in {"cuda", "cpu"}:
|
| 81 |
+
raise ValueError("Device must be either 'cuda' or 'cpu'.")
|
| 82 |
+
|
| 83 |
+
raw_text = text.strip()
|
| 84 |
+
if not raw_text:
|
| 85 |
+
raise ValueError("Input text is empty.")
|
| 86 |
+
|
| 87 |
+
chunks = split_text_into_chunks(raw_text, max_chars=max_chars)
|
| 88 |
+
if not chunks:
|
| 89 |
+
raise ValueError("Text could not be segmented into valid chunks.")
|
| 90 |
+
|
| 91 |
+
print(f"📄 Total chunks: {len(chunks)} (≤ {max_chars} chars each)")
|
| 92 |
+
|
| 93 |
+
if chunk_dir:
|
| 94 |
+
os.makedirs(chunk_dir, exist_ok=True)
|
| 95 |
+
|
| 96 |
+
ref_text_raw = Path(ref_text_path).read_text(encoding="utf-8")
|
| 97 |
+
|
| 98 |
+
tts = VieNeuTTS(
|
| 99 |
+
backbone_repo=backbone_repo,
|
| 100 |
+
backbone_device=device,
|
| 101 |
+
codec_repo=codec_repo,
|
| 102 |
+
codec_device=device,
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
print("🎧 Encoding reference audio...")
|
| 106 |
+
ref_codes = tts.encode_reference(ref_audio_path)
|
| 107 |
+
|
| 108 |
+
generated_segments: List[np.ndarray] = []
|
| 109 |
+
|
| 110 |
+
for idx, chunk in enumerate(chunks, start=1):
|
| 111 |
+
print(f"🎙️ Chunk {idx}/{len(chunks)} | {len(chunk)} chars")
|
| 112 |
+
wav = tts.infer(chunk, ref_codes, ref_text_raw)
|
| 113 |
+
generated_segments.append(wav)
|
| 114 |
+
|
| 115 |
+
if chunk_dir:
|
| 116 |
+
chunk_path = os.path.join(chunk_dir, f"chunk_{idx:03d}.wav")
|
| 117 |
+
sf.write(chunk_path, wav, 24_000)
|
| 118 |
+
|
| 119 |
+
combined_audio = np.concatenate(generated_segments)
|
| 120 |
+
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
| 121 |
+
sf.write(output_path, combined_audio, 24_000)
|
| 122 |
+
|
| 123 |
+
print(f"✅ Saved combined audio to: {output_path}")
|
| 124 |
+
return output_path
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def parse_args() -> argparse.Namespace:
|
| 128 |
+
parser = argparse.ArgumentParser(description="Infer long text with VieNeu-TTS")
|
| 129 |
+
text_group = parser.add_mutually_exclusive_group(required=True)
|
| 130 |
+
text_group.add_argument(
|
| 131 |
+
"--text",
|
| 132 |
+
help="Raw UTF-8 text content to synthesize.",
|
| 133 |
+
)
|
| 134 |
+
text_group.add_argument(
|
| 135 |
+
"--text-file",
|
| 136 |
+
help="Path to a UTF-8 text file to synthesize.",
|
| 137 |
+
)
|
| 138 |
+
parser.add_argument(
|
| 139 |
+
"--ref-audio",
|
| 140 |
+
default="./sample/Vĩnh (nam miền Nam).wav",
|
| 141 |
+
help="Path to reference audio (.wav). Default: ./sample/Vĩnh (nam miền Nam).wav"
|
| 142 |
+
)
|
| 143 |
+
parser.add_argument(
|
| 144 |
+
"--ref-text",
|
| 145 |
+
default="./sample/Vĩnh (nam miền Nam).txt",
|
| 146 |
+
help="Path to reference text (UTF-8). Default: ./sample/Vĩnh (nam miền Nam).txt"
|
| 147 |
+
)
|
| 148 |
+
parser.add_argument(
|
| 149 |
+
"--output",
|
| 150 |
+
default="./output_audio/long_text.wav",
|
| 151 |
+
help="Path to save the combined audio output.",
|
| 152 |
+
)
|
| 153 |
+
parser.add_argument(
|
| 154 |
+
"--chunk-output-dir",
|
| 155 |
+
default=None,
|
| 156 |
+
help="Optional directory to save individual chunk audio files.",
|
| 157 |
+
)
|
| 158 |
+
parser.add_argument(
|
| 159 |
+
"--max-chars",
|
| 160 |
+
type=int,
|
| 161 |
+
default=256,
|
| 162 |
+
help="Maximum characters per chunk before TTS inference.",
|
| 163 |
+
)
|
| 164 |
+
parser.add_argument(
|
| 165 |
+
"--device",
|
| 166 |
+
choices=["auto", "cuda", "cpu"],
|
| 167 |
+
default="auto",
|
| 168 |
+
help="Device to run inference on (auto=CUDA if available).",
|
| 169 |
+
)
|
| 170 |
+
parser.add_argument(
|
| 171 |
+
"--backbone",
|
| 172 |
+
default="pnnbao-ump/VieNeu-TTS",
|
| 173 |
+
help="Backbone repository ID or local path.",
|
| 174 |
+
)
|
| 175 |
+
parser.add_argument(
|
| 176 |
+
"--codec",
|
| 177 |
+
default="neuphonic/neucodec",
|
| 178 |
+
help="Codec repository ID or local path.",
|
| 179 |
+
)
|
| 180 |
+
return parser.parse_args()
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def main():
|
| 184 |
+
args = parse_args()
|
| 185 |
+
ref_audio_path = Path(args.ref_audio)
|
| 186 |
+
if not ref_audio_path.exists():
|
| 187 |
+
raise FileNotFoundError(f"Reference audio not found: {ref_audio_path}")
|
| 188 |
+
|
| 189 |
+
ref_text_path = Path(args.ref_text)
|
| 190 |
+
if not ref_text_path.exists():
|
| 191 |
+
raise FileNotFoundError(f"Reference text not found: {ref_text_path}")
|
| 192 |
+
|
| 193 |
+
if args.text_file:
|
| 194 |
+
text_path = Path(args.text_file)
|
| 195 |
+
if not text_path.exists():
|
| 196 |
+
raise FileNotFoundError(f"Text file not found: {text_path}")
|
| 197 |
+
raw_text = text_path.read_text(encoding="utf-8")
|
| 198 |
+
else:
|
| 199 |
+
raw_text = args.text.strip()
|
| 200 |
+
if not raw_text:
|
| 201 |
+
raise ValueError("Provided text is empty.")
|
| 202 |
+
device = (
|
| 203 |
+
"cuda"
|
| 204 |
+
if args.device == "auto" and torch.cuda.is_available()
|
| 205 |
+
else ("cpu" if args.device == "auto" else args.device)
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
infer_long_text(
|
| 209 |
+
text=raw_text,
|
| 210 |
+
ref_audio_path=str(ref_audio_path),
|
| 211 |
+
ref_text_path=str(ref_text_path),
|
| 212 |
+
output_path=args.output,
|
| 213 |
+
chunk_dir=args.chunk_output_dir,
|
| 214 |
+
max_chars=args.max_chars,
|
| 215 |
+
backbone_repo=args.backbone,
|
| 216 |
+
codec_repo=args.codec,
|
| 217 |
+
device=device,
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
if __name__ == "__main__":
|
| 222 |
+
main()
|
| 223 |
+
|
examples/sample_long_text.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Trong kỷ nguyên số, việc tiếp cận thông tin đã trở nên dễ dàng hơn bao giờ hết. Chúng ta có thể đọc báo trực tuyến, xem video và tham gia các lớp học từ xa chỉ với một chiếc điện thoại thông minh. Tuy nhiên, khối lượng nội dung khổng lồ này đôi khi khiến người học cảm thấy choáng ngợp. Một chiến lược hiệu quả là chia nhỏ tài liệu thành các đoạn ngắn, mỗi đoạn tập trung vào một ý chính, nhằm giúp não bộ xử lý thông tin tốt hơn. Khi áp dụng kỹ thuật này cho việc luyện nghe tiếng Việt, người học không chỉ cải thiện kỹ năng ngôn ngữ mà còn rèn luyện được khả năng tập trung và ghi nhớ. Ngoài ra, việc nghe lại giọng của chính mình qua các công cụ tổng hợp tiếng nói cũng giúp phát hiện lỗi phát âm và chỉnh sửa kịp thời.
|