Mục lục bài viết16 mục
- 1.Mục tiêu sản phẩm và tiêu chí chất lượng – độ trễ
- 2.Kiến trúc pipeline tổng thể
- 3.Chọn mô hình TTS và phương án dự phòng
- 4.Thiết lập môi trường trên Windows 11 và ghi chú Linux
- 5.Tiền xử lý dữ liệu giọng hát tham chiếu
- 6.Chiến lược tạo voice profile: zero-shot, RVC-to-speech, fine-tune nhẹ
- 7.Kiểm soát kiểu đọc: preset Tin tức, Kể chuyện, Trò chuyện
- 8.Thiết kế ứng dụng PC: module hóa, API nội bộ và web UI
- 9.Hạ tầng và tối ưu hiệu năng
- 10.Lộ trình dữ liệu và độ trễ đầu cuối
- 11.Dựng bản demo end-to-end
- 12.Đánh giá chất lượng và đo lường
- 13.Rủi ro pháp lý và đạo đức
- 14.Lỗi thường gặp và cách cứu nhanh
- 15.Phương án mở rộng và đóng gói
- 16.Kết luận
Mình giúp bạn dựng demo TTS chạy local trên PC: lấy timbre từ a cappella, xử lý tham chiếu, dùng XTTS v2 làm chính và Piper + RVC dự phòng, tạo 3 kiểu đọc rõ rệt, tối ưu độ trễ và kiểm thử MOS, thanh điệu, RTF. Ví dụ trên Windows 11, kèm ghi chú Linux.
Mục tiêu sản phẩm và tiêu chí chất lượng – độ trễ
Đích của bản demo là chạy hoàn toàn local trên PC, nhập văn bản tiếng Việt và phát ra giọng đọc giữ đúng timbre lấy từ file a cappella, có ít nhất 3 kiểu đọc khác nhau gồm Tin tức, Kể chuyện, Trò chuyện. Mình chốt các tiêu chí đo được để hai bên cùng kiểm:
- Mục tiêu nghe hiểu và thanh điệu
- Hiểu nội dung: người nghe chấm MOS theo thang 1 đến 5 cho tiêu chí dễ nghe và dễ hiểu ý. Mục tiêu MOS trung bình mỗi kiểu đọc ≥ 3.8, ngưỡng chấp nhận tối thiểu ≥ 3.5.
- Giữ đúng thanh điệu: tỷ lệ nhận đúng thanh theo tai nghe trên bộ câu kiểm thử phủ đủ 6 thanh ≥ 92 phần trăm, ngưỡng chấp nhận tối thiểu ≥ 90 phần trăm. Tính theo số âm tiết đúng thanh chia cho tổng âm tiết, chấm độc lập bởi ít nhất 3 người Việt.
- Ổn định phát âm: tỷ lệ âm tiết bị lặp hoặc nuốt không quá 2 phần trăm. Nếu bất kỳ câu nào lặp liên tiếp trên 3 âm tiết thì coi là fail câu đó.
- Mục tiêu kiểu đọc
- Có 3 preset khác biệt rõ ràng. Bài test ABX nhãn kiểu đọc đạt tỷ lệ nhận diện đúng ≥ 80 phần trăm. Nếu dưới 75 phần trăm ở bất kỳ preset nào thì xem là chưa đủ khác biệt.
- Không được chuyển sang giọng hát: người nghe không được mô tả đầu ra là nghe như hát ở quá 10 phần trăm câu.
- Mục tiêu độ trễ
- RTF mục tiêu cho câu 20 giây ≤ 0.7 ở GPU NVIDIA 8 GB sau warm up, cache sẵn embedding. RTF tính bằng thời gian sinh toàn bộ file WAV chia cho độ dài âm thanh. p95 RTF ≤ 0.85. Ngưỡng chấp nhận tối thiểu: median RTF ≤ 0.8 và p95 ≤ 0.95.
- Thời gian sẵn sàng phát cho mỗi câu chia theo chunk nội bộ không vượt quá 2 giây cho chunk đầu tiên khi đã có cache embedding.
- Bộ test và cách chấm
- Văn bản: 20 câu, mỗi câu 12 đến 25 âm tiết, bao phủ đủ 6 thanh và các tổ hợp phụ âm-nguyên âm phổ biến. Dùng cùng một văn bản cho cả 3 preset.
- Quy trình: với mỗi preset, synth toàn bộ 20 câu, 3 người nghe độc lập chấm MOS và đánh dấu đúng sai thanh theo âm tiết. MOS lấy trung bình người nghe, tỷ lệ đúng thanh lấy theo đa số phiếu từng âm tiết. Độ trễ đo bằng timestamp từ lúc nhấn synth đến lúc file âm thanh sẵn sàng, log riêng thời gian synth để tính RTF.
- Tiêu chí pass-fail để ra mắt bản demo
- Pass khi đồng thời đạt: MOS trung bình mỗi preset ≥ 3.5, tỷ lệ đúng thanh mỗi preset ≥ 90 phần trăm, median RTF ≤ 0.8 và p95 ≤ 0.95, lỗi lặp hoặc nuốt ≤ 2 phần trăm âm tiết, 3 preset phân biệt được ≥ 75 phần trăm.
- Fail nếu bất kỳ tiêu chí tối thiểu nào không đạt trên 2 trong 3 preset, hoặc có sự cố lặp dài gây đứt mạch nghe trên từ 5 phần trăm câu trong bộ test.
Kiến trúc pipeline tổng thể
Luồng của ứng dụng chạy local xoay quanh một tham chiếu giọng và một yêu cầu đọc văn bản. Mình chia thành các mô-đun độc lập, có cache và log để bạn vận hành ổn định, dễ đo lỗi, dễ sửa.
Các bước thực hiện
- Nạp tệp tham chiếu và kiểm tra định dạng
- Đầu vào: tệp a cappella hoặc vocal đã tách nhạc của cùng một người. Nhận WAV PCM 16-bit, FLAC, M4A. Khuyến nghị WAV hoặc FLAC để tránh artefact nén.
- Kiểm: đọc metadata, chuẩn hóa mono, resample nội bộ về 16 kHz phục vụ phân tích tiền xử lý. Tính SHA-256 để làm khóa cache. Áp giới hạn kích thước tệp theo cấu hình, ví dụ tối đa 10 phút để tránh IO treo UI.
- Tiền xử lý chọn đoạn sạch
- Chuẩn hóa cơ bản: loudness normalize về khoảng −23 LUFS integrated, peak dưới −1 dBFS. High-pass 60 Hz bậc nhẹ để bỏ rù nền.
- VAD: cắt khoảng lặng và phát hiện đoạn giọng rõ. Lấy 2 đến 4 đoạn, mỗi đoạn 3 đến 8 giây, tổng 10 đến 20 giây sạch để trích embedding. Lưu lại timecode để tái hiện đúng lựa chọn khi cache.
- Khử vang nhẹ – điều kiện
- Nếu chỉ báo đuôi vang cao, chạy dereverb nhẹ bằng WPE hoặc spectral gating rất dè chừng. Mục tiêu giảm tail chứ không làm khô tuyệt đối. Lưu cả phiên bản gốc và phiên bản dereverb cho mục A/B.
- Làm mượt F0 chỉ cho bước trích embedding – tùy chọn
- Khi phát hiện rung sâu, trích F0 và lọc rung ở dải 4 đến 8 Hz để giảm vibrato. Resynthesize nhẹ để tạo bản “ít rung” dùng riêng cho trích embedding. Không dùng file này làm tham chiếu nghe hoặc phát trực tiếp.
- Trích embedding tham chiếu
- Dòng chính: đưa bản tham chiếu đã xử lý ở bước 2 đến 4 vào nhánh speaker encoder của XTTS v2 để lấy vector embedding. Cache theo khóa gồm SHA-256 của tệp gốc, cờ dereverb, cờ mượt F0, và phiên bản model.
- Fallback khi bản cài không lộ API embedding: cache tệp tham chiếu đã xử lý và tái sử dụng nó làm speaker_wav cho mọi lượt synth cùng phiên.
- Tạo voice profile và quản lý phiên
- Voice profile gồm: id, refsha256, đường dẫn tham chiếu xử lý, timestamp, embedding đã cache nếu có. Phiên làm việc lưu voiceprofileid, preset kiểu đọc, ngôn ngữ vi, tham số lengthscale và temperature. TTL phiên, ví dụ 30 phút không hoạt động sẽ thu hồi GPU và giải phóng embedding khỏi VRAM.
- Chuẩn hóa văn bản và tách câu
- Chuẩn hóa Unicode NFC để giữ đúng dấu tiếng Việt. Làm sạch ký tự lạ. Tách câu theo dấu . , ; ? ! và xuống dòng. Chunk mục tiêu mỗi mệnh đề tương ứng 4 đến 6 giây âm thanh để ổn định độ trễ và tránh lặp. Chèn kế hoạch ngắt 150 đến 250 ms giữa các mệnh đề khi ghép.
- Tổng hợp bằng XTTS v2 với tham số kiểm soát kiểu đọc
- Với mỗi chunk văn bản: gọi XTTS v2 ở language="vi", truyền speakerwav hoặc speakerembedding từ cache. Điều khiển nhịp đọc chủ yếu bằng length_scale và độ biến thiên bằng temperature. Kéo preset kiểu đọc từ phiên – ví dụ Tin tức, Kể chuyện, Trò chuyện – để set tham số nhất quán theo từng yêu cầu.
- Vocoder và ghép ngắt
- Dùng vocoder tích hợp trong XTTS v2 để lấy waveform. Ghép lần lượt các chunk, chèn khoảng lặng đúng kế hoạch. Crossfade rất ngắn ở biên nếu cần để triệt click. Chuẩn hóa loudness đầu ra về khoảng −23 đến −20 LUFS để phát đồng đều trên UI.
- Cache thành phẩm ngắn và phát trực tiếp
- Với yêu cầu lặp lại cùng văn bản – preset – profile, có thể cache thành phẩm ở mức câu. Mặc định chỉ cache embedding và văn bản đã tách câu, tránh tốn dung lượng. Stream phát ngay khi chunk đầu hoàn tất để giảm cảm nhận trễ.
- Ghi log chất lượng và thời gian
- Ingest log: ref_sha256, định dạng gốc, SR sau chuẩn hóa, LUFS, chỉ báo rung F0 trước – sau xử lý, chỉ báo tail energy sau nguyên âm. Gắn cờ đã dereverb và đã mượt F0 hay chưa.
- Inference log: model id, preset, length_scale, temperature, số chunk, tổng thời gian sinh, tổng độ dài âm thanh, RTF, GPU sử dụng, VRAM đỉnh, cờ lặp âm nếu phát hiện. Lưu thêm checkpoint tham chiếu để tái lập kết quả.
- A/B hook: lưu đường dẫn cả 2 phiên bản tham chiếu gốc và xử lý để bạn nghe so sánh khi cần chẩn đoán.
Sơ đồ mô-đun – dịch vụ
- Web UI
- Chọn tệp tham chiếu, hiển thị progress upload, chọn preset, nhập văn bản, bấm synth, phát preview.
- API nội bộ
- Endpoint upload và validate tham chiếu, endpoint tạo voice profile, endpoint synth theo profile và preset, endpoint lấy log.
- Worker tiền xử lý
- Normalize, VAD, dereverb nhẹ, mượt F0 tùy điều kiện. Xuất tham chiếu đã xử lý và metadata đo lường.
- Bộ trích embedding và cache
- Tính speaker embedding từ tham chiếu đã xử lý, lưu RAM và đĩa. Tùy bản cài đặt, cho phép giữ embedding trên GPU để tránh copy lặp.
- Text pipeline
- NFC, clean, chunker theo dấu câu, cài đặt ngắt hậu kỳ.
- XTTS v2 inference
- Sinh audio theo chunk, tham số hóa theo preset, trả về waveform từng phần để UI phát sớm.
- Joiner và post
- Ghép, thêm ngắt, loudness normalize đầu ra, ghi file WAV.
- Storage
- Thư mục file tham chiếu gốc và đã xử lý, thư mục kết quả, SQLite cho metadata, LRU RAM cache cho embedding.
- Logger
- Ghi JSON line cho ingest và inference, có thể đẩy thêm vào Windows Event Log hoặc journald trên Linux nếu bạn muốn gom tập trung.
Ví dụ bản ghi log đầy đủ cho một lượt synth
{
"ts": "2026-07-18T10:15:33.412Z",
"session_id": "s_9f2c7a",
"voice_profile_id": "vp_cafe123",
"ref_sha256": "b8f1c7f0a9b64c06d2e8b2b4b9d1a0a1c3e7d5f2a9c0e1f4b6d7a8c9e0f1a2b3",
"preproc": {
"sr": 16000,
"lufs": -23.1,
"hp_hz": 60,
"vad_total_sec": 18.4,
"dereverb": "wpe",
"f0_smooth": true,
"vibrato_rms_semitone_before": 1.15,
"vibrato_rms_semitone_after": 0.42,
"tail_energy_db_200ms": -18.7
},
"tts": {
"model": "xtts_v2",
"language": "vi",
"preset": "tin_tuc",
"length_scale": 0.94,
"temperature": 0.55
},
"runtime": {
"chunks": 4,
"gen_sec": 11.6,
"audio_sec": 20.1,
"rtf": 0.58,
"gpu": "RTX3060_8GB",
"vram_peak_gb": 5.9
},
"quality_probe": {
"f0_outlier_pct": 1.8,
"repeat_flag": false
}
}
Bản mẫu có biến để bạn tích hợp logger của mình
{
"ts": "ISO8601_UTC",
"session_id": "SESSION_ID",
"voice_profile_id": "VOICE_PROFILE_ID",
"ref_sha256": "REF_SHA256",
"preproc": {
"sr": SAMPLE_RATE_ANALYSIS,
"lufs": LUFS_INTEGRATED,
"hp_hz": HPF_CUTOFF,
"vad_total_sec": VAD_SECONDS,
"dereverb": "none|wpe|gate",
"f0_smooth": true_or_false,
"vibrato_rms_semitone_before": VIB_BEFORE,
"vibrato_rms_semitone_after": VIB_AFTER,
"tail_energy_db_200ms": TAIL_DB_AT_200MS
},
"tts": {
"model": "xtts_v2",
"language": "vi",
"preset": "PRESET_NAME",
"length_scale": LENGTH_SCALE,
"temperature": TEMPERATURE
},
"runtime": {
"chunks": N_CHUNKS,
"gen_sec": GEN_SECONDS,
"audio_sec": AUDIO_SECONDS,
"rtf": RTF_VALUE,
"gpu": "GPU_NAME",
"vram_peak_gb": VRAM_GB
},
"quality_probe": {
"f0_outlier_pct": F0_OUTLIER_PCT,
"repeat_flag": true_or_false
}
}
Chốt lại, pipeline này tách bạch rõ 3 nhánh quan trọng: nhánh chuẩn hóa – làm sạch tham chiếu để giữ timbre, nhánh sinh TTS theo preset để kiểm soát kiểu đọc, và nhánh cache – log để duy trì tốc độ và khả năng chẩn đoán. Bạn chỉ áp dụng dereverb và mượt F0 cho dữ liệu tham chiếu dùng trích embedding, tuyệt đối không tác động vào audio đầu ra TTS. Ở mức triển khai, giữ embedding theo khóa ref_sha256 và cấu hình xử lý giúp tránh trích lại khi bạn thử nhiều preset hoặc nhiều văn bản trong cùng một phiên.
Chọn mô hình TTS và phương án dự phòng
Mình chọn XTTS v2 trong Coqui TTS làm phương án chính vì nó hỗ trợ tiếng Việt và cho phép zero-shot từ một tệp tham chiếu duy nhất qua tham số speakerwav. Điểm cần nắm chắc: XTTS v2 không tách riêng timbre và style từ hai tệp khác nhau, nên bạn kiểm soát kiểu đọc chủ yếu bằng lengthscale, temperature và dấu câu. Khi tham chiếu là hát a cappella, mô hình có xu hướng lôi theo phong cách hát, vì vậy cần chuẩn bị phương án dự phòng chạy hoàn toàn local: sinh giọng đọc trung tính bằng một TTS tiếng Việt nền như Piper rồi chuyển timbre sang giọng mục tiêu bằng RVC khi zero-shot không ổn định.
Cách dùng XTTS v2 đúng trọng tâm
- Khi nào hợp lý: bạn có 15 đến 45 giây tham chiếu từ hát ít luyến ít vang, hoặc tốt hơn là 20 đến 60 giây nói sạch của cùng người. Mục tiêu là bắt timbre mà không kéo theo style hát.
- Kiểm soát kiểu đọc:
- length_scale: tăng giảm nhịp đọc. < 1 nhanh hơn, > 1 chậm hơn. Thử 0.94 cho tin tức, 1.08 cho kể chuyện, 1.0 cho trò chuyện.
- temperature: điều tiết độ ngẫu nhiên. Với tiếng Việt nên giữ vừa phải để không vỡ thanh điệu, ví dụ 0.55 đến 0.65.
- Dấu câu và xuống dòng giúp gợi ngắt nghỉ tự nhiên theo mệnh đề.
- Một số bản cài có thêm topp hoặc repetitionpenalty. Nếu API báo tham số không hợp lệ, bỏ chúng và quay về cặp length_scale + temperature.
- Gọi từ Python API của coqui-ai TTS:
Ví dụ hoàn chỉnh chạy GPU, tham chiếu đã chuẩn hóa, preset Tin tức
from TTS.api import TTS
tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to("cuda")
tts.tts_to_file(
text="Bản tin sáng nay, nhiệt độ tại Hà Nội giảm nhẹ, trời nhiều mây.",
speaker_wav="data/ref_clean.wav",
language="vi",
file_path="out_tintuc.wav",
length_scale=0.94,
temperature=0.55
)
Biến thể có tham số hóa
# TEXT, SPEAKER_WAV, LANG, OUT, L_SCALE, TEMP
tts.tts_to_file(
text=TEXT,
speaker_wav=SPEAKER_WAV,
language=LANG, # "vi"
file_path=OUT,
length_scale=L_SCALE, # 0.94|1.00|1.08
temperature=TEMP # 0.55..0.65
)
Điều kiện đổi hướng sang phương án dự phòng Piper + RVC
- Bạn synth 15 đến 20 câu kiểm thử tiếng Việt mà vẫn nghe rung giọng như hát, nhầm thanh điệu nhiều ở hỏi ngã sắc, hoặc xuất hiện lặp cụm âm tiết dù đã tách câu hợp lý.
- Tham chiếu bắt buộc là hát có nhiều vibrato hoặc vang, bạn không thể lấy được một đoạn nói sạch của cùng người.
- Bạn cần ba kiểu đọc ổn định và đồng nhất theo preset, nhưng zero-shot lệch style theo từng câu.
Phương án dự phòng chạy hoàn toàn local: Piper sinh đọc trung tính, RVC chuyển timbre
- Ý tưởng: dùng một TTS tiếng Việt nền để sinh giọng đọc trung tính theo đúng preset, sau đó dùng RVC – một công cụ voice conversion – chuyển timbre sang giọng mục tiêu. Như vậy style đọc do bạn điều khiển từ TTS nền, còn RVC chỉ lo giữ màu giọng.
- Bước 1 – Tạo đọc trung tính bằng Piper
- Cài Piper và tải một model tiếng Việt. Dùng tốc độ, nhịp đọc từ preset bạn muốn.
- Ví dụ CLI trên Windows PowerShell hay Linux:
piper -m voices/vi/model.onnx -c voices/vi/model.onnx.json \
-f piper_out.wav -l vi-VN <<< "Chào bạn, đây là phần đọc thử cho preset tin tức."
- Biến thể có tham số hóa
piper -m PATH_TO_VI_MODEL.onnx -c PATH_TO_VI_MODEL.json \
-f OUT_WAV -l vi-VN <<< "YOUR_TEXT"
- Bước 2 – Chuyển timbre sang giọng mục tiêu bằng RVC
- Mở GUI RVC local, nạp checkpoint giọng mục tiêu bạn đã huấn luyện, chọn pitch extractor RMVPE, bật theo dõi F0.
- Nạp piper_out.wav làm input, giữ tốc độ tự nhiên, tránh transpose quá mạnh, bật smoothing F0 vừa phải.
- Xuất rvc_out.wav – đây là giọng đọc trung tính mang timbre mục tiêu.
- Ứng dụng trong app:
- Với chế độ preset, sinh tất cả bằng Piper để đảm bảo nhịp điệu và dấu câu đúng ý, rồi chuyển timbre bằng RVC trước khi phát cho người dùng.
- Nếu bạn vẫn muốn dùng XTTS v2 nhưng tách style khỏi hát, có thể tạo một đoạn tham chiếu bằng cách: Piper sinh 15 đến 30 giây nói trung tính, RVC chuyển timbre sang giọng mục tiêu, sau đó dùng file này làm speaker_wav cho XTTS v2 để synth văn bản dài hơn.
Đánh đổi chất lượng – độ trễ khi chọn mỗi hướng
- XTTS v2 zero-shot
- Ưu: pipeline đơn giản, độ trễ thấp hơn vì chỉ một lần suy luận, đồng nhất ngôn ngữ tiếng Việt tốt, không cần model VC.
- Nhược: phụ thuộc chất lượng tham chiếu, dễ lôi theo style hát nếu chỉ có a cappella, kiểm soát kiểu đọc giới hạn ở length_scale, temperature và dấu câu.
- Piper + RVC
- Ưu: kiểm soát kiểu đọc rõ, ổn định thanh điệu do TTS nền đã là tiếng Việt, tách bạch vai trò style vs timbre.
- Nhược: thêm một lần suy luận VC nên độ trễ tăng; có rủi ro artefact chuyển giọng nếu model RVC chưa tốt; cần bảo trì thêm một checkpoint VC.
Quy tắc chọn hướng trong demo
- Mặc định bắt đầu với XTTS v2. Nếu sau một vòng A/B 15 đến 20 câu vẫn sai thanh điệu hoặc rung như hát, chuyển sang Piper + RVC để chốt bản demo ổn định. Khi đã ổn, bạn có thể quay lại tinh chỉnh tham chiếu hoặc thử fine-tune nhẹ XTTS v2 ở giai đoạn mở rộng.
- Dù theo hướng nào, giữ preset thống nhất bằng tham số và dấu câu, log độ trễ đầu cuối theo từng mệnh đề để theo dõi ảnh hưởng của việc thêm RVC vào pipeline.
Thiết lập môi trường trên Windows 11 và ghi chú Linux
Mình đi theo lộ trình an toàn: tách môi trường bằng venv hoặc conda, cài driver NVIDIA ổn định, cài PyTorch bản CUDA khớp driver, rồi mới cài Coqui TTS, FFmpeg, các thư viện xử lý WPE và trích F0, cùng Gradio hoặc FastAPI cho web UI. Làm xong, kiểm tra CUDA và ffmpeg có sẵn trong PATH.
Các bước trên Windows 11 bằng PowerShell
- Chuẩn bị Python 64-bit và tạo môi trường ảo
- Cài Python 3.11 từ Microsoft Store hoặc winget.
winget install Python.Python.3.11
python --version
- Tạo và kích hoạt venv trong thư mục dự án.
python -m venv .venv
.\.venv\Scripts\Activate
python -m pip install -U pip setuptools wheel
- Cập nhật driver NVIDIA và kiểm tra GPU
- Cập nhật driver qua GeForce Experience hoặc trang NVIDIA. Kiểm tra:
nvidia-smi
- Cài PyTorch với CUDA khớp driver
- Cài bản CUDA 12.1 phổ biến. Nếu máy bạn chỉ hỗ trợ 11.8, thay cu121 bằng cu118.
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
- Kiểm tra CUDA khả dụng.
python - << 'PY'
import torch
print("CUDA available:", torch.cuda.is_available())
print("torch.version.cuda:", torch.version.cuda)
print("GPU:", torch.cuda.get_device_name(0) if torch.cuda.is_available() else "CPU")
PY
- Nếu CUDA available trả về False nhưng bạn có GPU, gỡ bộ CPU-only rồi cài lại bản cu121.
pip uninstall -y torch torchvision torchaudio
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
- Cài Coqui TTS
- Cài sau khi đã có torch đúng bản để tránh bị kéo nhầm bản CPU.
pip install TTS
- Cài FFmpeg vào PATH
- Cách 1 winget:
winget install FFmpeg.FFmpeg
ffmpeg -hide_banner -version
- Cách 2 Chocolatey:
choco install ffmpeg -y
ffmpeg -hide_banner -version
- Cài thư viện dereverb WPE
- Chọn một trong hai. Bạn có thể cài cả hai để thử nghiệm.
pip install nara-wpe
pip install pyroomacoustics soundfile
- Cài trích F0 RMVPE hoặc CREPE
- Phương án đơn giản dùng PyTorch: torchcrepe – chạy tốt trên GPU đã cài torch.
pip install torchcrepe
python - << 'PY'
import torchcrepe, torch
print("torchcrepe ok, CUDA:", torch.cuda.is_available())
PY
- CREPE bản gốc dùng TensorFlow, thường chạy CPU. Cài khi bạn không cần GPU cho trích F0.
pip install crepe
python - << 'PY'
import crepe
print("crepe ok")
PY
- Nếu bạn cần RMVPE: mô hình thường phát hành dạng ONNX. Cài runtime, còn file rmvpe.onnx bạn tải riêng và nạp trong code.
pip install onnxruntime-gpu
python - << 'PY'
import onnxruntime as ort
print("ONNX Runtime GPU:", ort.get_device())
PY
- Cài web UI và server
- Chọn một trong hai tùy nhu cầu.
pip install gradio
pip install fastapi uvicorn[standard]
- Biến môi trường hữu ích trên Windows
- Giảm phân mảnh bộ nhớ CUDA khi chạy lâu.
setx PYTORCH_CUDA_ALLOC_CONF "max_split_size_mb:64"
- Nếu ffmpeg chưa có trong PATH sau khi cài bản zip thủ công, thêm đường dẫn bin rồi mở terminal mới.
setx PATH "$env:PATH;C:\tools\ffmpeg\bin"
Ghi chú Linux – Ubuntu/Debian
- Cài gói hệ thống, Python 3.11 và venv.
sudo apt update
sudo apt install -y python3.11 python3.11-venv python3-pip ffmpeg build-essential
python3.11 -m venv .venv
source .venv/bin/activate
python -m pip install -U pip setuptools wheel
- Kiểm tra GPU và driver.
nvidia-smi
- Cài PyTorch CUDA 12.1 hoặc 11.8 tương tự Windows.
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
python - << 'PY'
import torch
print("CUDA available:", torch.cuda.is_available(), "CUDA:", torch.version.cuda)
PY
- Cài TTS, WPE, pitch extractor, web UI.
pip install TTS
pip install nara-wpe pyroomacoustics soundfile
pip install torchcrepe # hoặc: pip install crepe
pip install gradio # hoặc: pip install fastapi uvicorn[standard]
ffmpeg -hide_banner -version
Tùy chọn dùng conda – Windows hoặc Linux
- Conda giúp cài torch CUDA gọn và cô lập môi trường.
conda create -n tts python=3.11 -y
conda activate tts
conda install -y pytorch torchvision torchaudio pytorch-cuda=12.1 -c pytorch -c nvidia
pip install TTS gradio nara-wpe pyroomacoustics soundfile torchcrepe
Mẹo tránh xung đột phiên bản
- Chỉ dùng một công cụ quản lý môi trường tại một thời điểm – venv hoặc conda. Đừng trộn.
- Luôn cài torch trước TTS để tránh kéo nhầm bản CPU. Nếu lỡ kéo sai, uninstall và cài lại qua index-url như trên.
- Ghim phiên bản chính khi đã chạy ổn để tái lập dễ dàng.
pip freeze > requirements.txt
Kiểm tra nhanh toàn bộ môi trường TTS
# run: python check_env.py
import os, subprocess, torch
print("CUDA available:", torch.cuda.is_available(), "CUDA:", torch.version.cuda)
print("Device:", torch.cuda.get_device_name(0) if torch.cuda.is_available() else "CPU")
print("ffmpeg:", subprocess.run(["ffmpeg","-version"], capture_output=True, text=True).stdout.splitlines()[0])
from TTS.api import TTS
tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to("cuda" if torch.cuda.is_available() else "cpu")
print("XTTS v2 loaded")
Tiền xử lý dữ liệu giọng hát tham chiếu
Mục tiêu ở bước này là biến file a cappella hoặc vocal tách nhạc thành một tham chiếu ngắn, sạch, giàu timbre nhưng ít bám style hát để trích embedding ổn định. Bạn sẽ kiểm tra nhanh chất lượng, chuẩn hóa chuẩn kỹ thuật, cắt VAD lấy 10 đến 20 giây thoại sạch, dereverb nhẹ khi cần, và chỉ khi rung quá sâu thì mới làm mượt F0 bằng WORLD cho đúng mục đích trích embedding.
Quy trình thực hiện
- Kiểm tra nhanh file nguồn để biết có cần xử lý
- Nghe tai ở mức -12 dBFS: chú ý pre-echo, lưng lửng reverb, run F0 đều đặn theo nhịp.
- Thông tin kỹ thuật bằng ffprobe và ước lượng loudness để biết có clipping:
ffprobe -v error -show_entries stream=sample_rate,channels,bit_rate -of json input_acapella.wav
ffmpeg -hide_banner -i input_acapella.wav -filter_complex ebur128 -f null -
- Đo nhanh hai chỉ báo tự động sau khi đã resample mono 16 kHz ở bước 2:
- Vibrato RMS trên F0 đã bỏ xu thế chậm. Nếu phần lớn đoạn kéo dài vượt khoảng 0.8 bán cung, nên cân nhắc làm mượt F0 trước khi trích embedding.
- Tail energy sau nguyên âm: nếu năng lượng vỏ không rơi về nền ít nhất khoảng 20 dB trong cửa sổ 100 đến 300 ms, nên dereverb nhẹ.
- Chuẩn hóa mono, tần số mẫu, high-pass và loudness normalize
- Mục đích: đồng nhất pipeline xử lý, giảm rù nền và tránh clipping. Dùng 16 kHz mono để khớp tham số WPE dễ triển khai. XTTS v2 sẽ tự nội suy nếu tham chiếu không đúng tần số mẫu nó mong muốn.
- Chuyển mono 16 kHz 16-bit và high-pass 60 Hz bậc 2:
ffmpeg -y -i input_acapella.wav -ac 1 -ar 16000 -af "highpass=f=60:order=2" pre_mono16.wav
- Loudness normalize mục tiêu khoảng −23 LUFS integrated, true peak dưới −1 dBFS bằng loudnorm hai lượt. Đo và áp dụng tự động bằng Python:
# run: python loudnorm_2pass.py
import json, subprocess, shlex, tempfile, os
inp = "pre_mono16.wav"; out = "pre_mono16_lufs.wav"
# Pass 1
cmd1 = f'ffmpeg -hide_banner -y -i {shlex.quote(inp)} -af loudnorm=I=-23:LRA=7:TP=-1:print_format=json -f null -'
p1 = subprocess.run(cmd1, shell=True, capture_output=True, text=True)
report = p1.stderr.split("{" ,1)[-1].rsplit("}",1)[0]
m = json.loads("{"+report+"}")
# Pass 2
af = ("loudnorm=I=-23:LRA=7:TP=-1:"
f"measured_I={m['input_i']}:measured_LRA={m['input_lra']}:"
f"measured_TP={m['input_tp']}:measured_thresh={m['input_thresh']}:"
"offset={:.2f}:linear=true:print_format=summary").format(m['target_offset'])
cmd2 = f'ffmpeg -hide_banner -y -i {shlex.quote(inp)} -af "{af}" {shlex.quote(out)}'
subprocess.check_call(cmd2, shell=True)
print("Wrote", out)
- VAD để cắt khoảng im lặng và chọn clip 3 đến 8 giây, gom 10 đến 20 giây sạch
- Ưu tiên WebRTC VAD vì ổn định trên tiếng hát đã tách. Lấy các đoạn có biên độ vừa phải, phụ âm rõ, ít sustain dài. Mục tiêu: tổng 10 đến 20 giây.
# run: python cut_vad.py
import webrtcvad, soundfile as sf, numpy as np
def frame_bytes(x16k_mono, sr=16000, ms=30):
assert sr==16000
step = int(sr*ms/1000)
x = (x16k_mono*32767).astype(np.int16).tobytes()
for i in range(0, len(x), step*2):
yield x[i:i+step*2], i//2, (i//2)+step
vad = webrtcvad.Vad(2) # 0..3, 2 cân bằng
x, sr = sf.read("pre_mono16_lufs.wav")
x = x.astype(np.float32)
segments = []
cur = []
for b, s, e in frame_bytes(x, sr=sr, ms=30):
voiced = vad.is_speech(b, sr)
if voiced: cur.append((s,e))
elif cur:
seg = (cur[0][0], cur[-1][1]); cur=[]
segments.append(seg)
if cur: segments.append((cur[0][0], cur[-1][1]))
# Hợp nhất các đoạn ngắn liền kề và lọc độ dài 3..8 s
def merge_close(segs, max_gap=0.2, sr=16000):
out=[]; buf=None
for s,e in segs:
if buf is None: buf=[s,e]; continue
if (s - buf[1]) <= int(max_gap*sr):
buf[1]=e
else:
out.append(tuple(buf)); buf=[s,e]
if buf: out.append(tuple(buf))
return out
segs = merge_close(segments)
cand = []
for s,e in segs:
dur = (e - s)/sr
if 3.0 <= dur <= 8.0: cand.append((s,e,dur))
# Nếu thiếu, cho phép 2..9 s nhưng sẽ cắt giữa
if len(cand)<3:
for s,e in segs:
dur = (e - s)/sr
if 2.0 <= dur <= 9.0: cand.append((s,e,dur))
# Lấy lần lượt đến khi đạt 10..20 s tổng
picked=[]; total=0.0
for s,e,d in sorted(cand, key=lambda z:-z[2]): # ưu tiên đoạn dài vừa
if total >= 20.0: break
take = min(d, 6.0) # không quá 6 s mỗi đoạn để giảm style hát
picked.append((s, int(s+take*sr)))
total += take
# Ghi ghép
out = []
for s,e in picked: out.append(x[s:e])
y = np.concatenate(out) if out else x[0:int(5*sr)]
sf.write("ref_vad_10to20s.wav", y, sr)
print("Picked secs:", len(y)/sr, "-> ref_vad_10to20s.wav")
- Nếu bạn cần đường tắt không cài VAD: có thể thử ffmpeg silenceremove để xén đầu đuôi rồi cắt tay, nhưng độ chính xác kém hơn:
ffmpeg -y -i pre_mono16_lufs.wav -af "silenceremove=start_periods=1:start_threshold=-40dB:stop_periods=1:stop_threshold=-40dB" trimmed.wav
- Quyết định có dereverb hoặc làm mượt F0 hay không
- Heuristic thực dụng nên dùng như cờ bật xử lý:
- Vibrato RMS trên đoạn đã VAD nếu vượt khoảng 0.8 bán cung trên nhiều khúc kéo dài, hãy cân nhắc làm mượt F0.
- Tail energy sau đỉnh nguyên âm không rơi đủ khoảng 20 dB trong 100 đến 300 ms, hãy dereverb nhẹ.
- Đo nhanh vibrato RMS bằng torchcrepe:
# run: python check_vibrato.py
import torch, torchcrepe, torchaudio, numpy as np
wav, sr = torchaudio.load("ref_vad_10to20s.wav")
wav = torchaudio.functional.resample(wav, sr, 16000); sr=16000
wav = wav.mean(dim=0, keepdim=True)
f0, pd = torchcrepe.predict(wav, sr, 5, 50, 550, model='full', batch_size=2048, device='cpu', return_periodicity=True)
f0 = f0.squeeze().numpy()
f0 = f0[(f0>0)]
med = np.median(f0)
semi = 12*np.log2(f0/med)
# loại xu thế chậm bằng cửa sổ 0.25 s
import scipy.signal as sg
win = int(0.25/(5e-3))
semi_smooth = sg.savgol_filter(semi, max(5,win|1), 3)
vib = semi - semi_smooth
rms = np.sqrt(np.mean(vib**2))
print("Vibrato RMS semitone:", round(rms,3))
- Dereverb nhẹ bằng WPE hoặc spectral gating cẩn trọng
- Ưu tiên WPE vì ít làm xỉn formant khi cấu hình nhẹ. Tham số gợi ý cho 16 kHz: frame 32 ms, hop 8 ms, delay 3, taps 2 hoặc 3, look-back 10 đến 20 frames, 1 đến 2 vòng lặp.
# run: python dereverb_wpe.py
import numpy as np, soundfile as sf, librosa
from nara_wpe.wpe import wpe
from nara_wpe.utils import stft, istft
y, sr = sf.read("ref_vad_10to20s.wav")
assert sr==16000
X = stft(y, size=512, shift=128) # 32 ms, hop 8 ms
Y = wpe(X, taps=2, delay=3, iterations=1)
z = istft(Y, size=512, shift=128).astype(np.float32)
sf.write("ref_wpe.wav", z, sr)
- Nếu không dùng WPE, dùng spectral gating rất nhẹ. Tránh ép mạnh làm mất sibilant:
# run: python dereverb_gate.py
import noisereduce as nr, soundfile as sf, numpy as np
y, sr = sf.read("ref_vad_10to20s.wav")
# Dùng chế độ stationary cho reverb tail, giảm nhẹ
z = nr.reduce_noise(y=y, sr=sr, stationary=True, prop_decrease=0.2, time_constant_s=0.4)
sf.write("ref_gate.wav", z.astype('float32'), sr)
- Tùy chọn làm mượt F0 bằng WORLD chỉ để trích embedding
- Chỉ làm khi vibrato sâu. Giữ spectral envelope và aperiodicity gốc, chỉ lọc quỹ đạo F0 để giảm rung.
# run: python devibrato_world.py
import pyworld as pw, numpy as np, soundfile as sf, scipy.signal as sg
y, sr = sf.read("ref_wpe.wav" if os.path.exists("ref_wpe.wav") else "ref_vad_10to20s.wav")
y = y.astype(np.float64)
_f0, t = pw.harvest(y, sr, frame_period=5.0) # 5 ms
sp = pw.cheaptrick(y, _f0, t, sr)
ap = pw.d4c(y, _f0, t, sr)
# Làm mượt F0 bằng Savitzky–Golay 150..250 ms
win = int(0.20/(5e-3))|1
f0 = sg.savgol_filter(_f0, win, 3)
y_synth = pw.synthesize(f0, sp, ap, sr)
sf.write("ref_world_smoothf0.wav", y_synth.astype(np.float32), sr)
- Nhắc lại: file đã làm mượt F0 này chỉ dùng để trích embedding speaker. Đừng dùng nó làm mẫu nghe so sánh style hay làm dữ liệu train.
- Chọn file tham chiếu cuối cùng để trích embedding và lưu cả ba phiên bản để A/B
- Thực dụng:
- Nếu mẫu gốc đã rất sạch, vibrato RMS dưới khoảng 0.5 bán cung và tail ngắn, dùng ngay refvad10to20s.wav.
- Nếu có vang nhưng vibrato không sâu, ưu tiên ref_wpe.wav.
- Nếu rung sâu, ưu tiên refworldsmoothf0.wav sau khi đã dereverb nhẹ.
- Luôn giữ cả ba phiên bản và nghe A/B nhanh 2 đến 3 câu synth để xác nhận version nào cho thanh điệu ổn định hơn rồi hẵng cache embedding.
Ngưỡng mềm và cảnh báo lạm tay
- Ngưỡng rung: nếu vibrato RMS đo được sau xử lý về quanh 0.3 đến 0.5 bán cung là đủ. Ép mượt hơn nữa thường làm giọng phẳng và dễ metallic khi resynthesize.
- Dereverb: mục tiêu giảm đuôi nghe thấy rõ, không cố làm khô tuyệt đối. Nếu nghe sibilant xỉn hoặc đầu phụ âm mờ, đã quá tay.
- Với vocal tách nhạc, artifact pha và pre-echo vốn đã làm yếu formant. Hãy giảm cường độ dereverb và chỉ làm mượt F0 khi thật cần thiết.
- Không áp dụng các xử lý này lên văn bản đầu vào TTS. Chúng chỉ dành cho file tham chiếu speaker embedding.
- Bất cứ khi nào phân vân, quay về quy tắc A/B: synth cùng một câu với 3 tham chiếu Gốc vs Dereverb nhẹ vs Dereverb + F0 mượt, chọn bản giữ thanh điệu rõ và ít run không chủ đích nhất.
Ví dụ nhanh end to end trên Windows PowerShell
- Ý: chuyển file a cappella thành tham chiếu 12 giây sạch có dereverb nhẹ bằng WPE.
ffmpeg -y -i ".\input_acapella.wav" -ac 1 -ar 16000 -af "highpass=f=60:order=2" pre_mono16.wav
python .\loudnorm_2pass.py
python .\cut_vad.py
python .\dereverb_wpe.py
# Kết quả: .\ref_wpe.wav dùng cho speaker_wav khi trích embedding
Chiến lược tạo voice profile: zero-shot, RVC-to-speech, fine-tune nhẹ
Mình đi theo lộ trình tăng dần độ can thiệp để giữ chất lượng và thời gian triển khai: thử zero-shot XTTS v2 trước, nếu thanh điệu vẫn chưa ổn thì dùng RVC để tạo pseudo-speech tiếng Việt trung tính làm tham chiếu hoặc làm tập dữ liệu, cuối cùng mới tính tới fine-tune nhẹ XTTS v2 với 6 đến 10 phút pseudo-speech sạch nhằm khóa ổn định thanh điệu.
Thứ tự thử nghiệm khuyến nghị
- Zero-shot XTTS v2 bằng tham chiếu ngắn và sạch
- Nếu có nói sạch: lấy 20 đến 60 giây gồm vài câu, ít vang, không nhạc nền. 30 giây là điểm ngọt thường đủ bắt timbre mà không kéo theo style phòng thu.
- Nếu chỉ có hát: chọn 15 đến 45 giây a cappella ít rung, phụ âm rõ, hạn chế sustain dài. Nếu buộc phải dùng đoạn có rung và vang, áp dụng dereverb nhẹ và làm mượt F0 chỉ cho bước trích embedding như phần trước đã chuẩn bị.
- Cách kiểm nhanh để quyết định dừng hay tiếp tục:
- Sinh 10 đến 20 câu kiểm thử phủ đủ 6 thanh điệu. Nếu nghe lẫn hỏi-ngã hoặc rung kéo luyến không chủ đích xuất hiện dày đặc, chuyển sang bước 2.
- Ví dụ gọi XTTS v2 zero-shot bằng Python trên GPU:
# Ý: synth giọng Việt từ tham chiếu 30 s, kiểm tra nhanh 3 kiểu đọc
from TTS.api import TTS
tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to("cuda")
ref = "ref_wpe.wav" # tham chiếu 15..45 s hát ít rung hoặc 20..60 s nói sạch
texts = [
"Hôm nay trời đẹp, chúng ta đi dạo một vòng quanh hồ nhé.",
"Bạn có thể kiểm tra lại lịch họp vào chiều thứ ba không?",
"Câu chuyện bắt đầu từ một buổi tối nhiều gió ở ngoại ô Hà Nội."
]
presets = {
"tin_tuc": dict(length_scale=0.94, temperature=0.55),
"ke_chuyen": dict(length_scale=1.08, temperature=0.65),
"tro_chuyen": dict(length_scale=1.00, temperature=0.60),
}
for name, p in presets.items():
for i, text in enumerate(texts, 1):
out = f"xtts_{name}_{i}.wav"
tts.tts_to_file(
text=text,
language="vi",
speaker_wav=ref,
file_path=out,
**p
)
print("Wrote", out)
- Biến số có thể thay đổi: language="vi", đường dẫn ref, bộ preset length_scale và temperature theo phiên bản bạn cài.
- RVC-to-speech để tạo pseudo-speech trung tính cho tham chiếu hoặc tập dữ liệu
- Mục tiêu: tách timbre khỏi style hát. Hai cách thực dụng:
- Cách A ưu tiên ổn định: sinh lời nói tiếng Việt trung tính bằng một TTS giọng bất kỳ, sau đó dùng RVC chuyển timbre sang giọng mục tiêu. Bạn sẽ nhận được pseudo-speech sạch về nhịp điệu đọc, rất hợp để làm tham chiếu speaker_wav hoặc gom thành tập 6 đến 10 phút cho fine-tune.
- Cách B nếu bạn chỉ muốn tận dụng đúng nội dung hát: làm mượt F0 để giảm rung, đưa cao độ về dải nói bằng f0upkey trong RVC ở mức nhỏ, rồi convert qua RVC. Cách này tiết kiệm công đoạn nhưng khó sạch bằng cách A.
- Thiết lập RVC khi convert sang nói:
- Pitch extractor: RMVPE, bật smoothing hoặc median filter để rung F0 còn nhẹ.
- f0upkey trong khoảng ±2 đến ±3 bán cung để đưa cao độ vào vùng nói của giới tính mục tiêu. Quá tay thường gây chipmunk hoặc đục giọng.
- Index rate khoảng 0.5 đến 0.75 để dựa nhiều hơn vào retrieval giúp giữ ổn timbre khi F0 vẫn dao động.
- Giới hạn f0min và f0max theo giới tính để tránh nhảy quãng tám sai.
- Khối lượng dữ liệu gợi ý và tác dụng:
- 30 giây pseudo-speech: đủ tốt làm tham chiếu zero-shot, thường ổn định hơn dùng hát thẳng cùng độ dài.
- 2 phút pseudo-speech: sử dụng làm tham chiếu hoặc kiểm thử pipeline fine-tune nhanh. Cải thiện có nhưng chưa bền về thanh điệu trên câu dài.
- 10 phút pseudo-speech sạch, không vang, đọc trung tính: đủ độ phủ kết hợp phụ âm-nguyên âm và 6 thanh để fine-tune nhẹ có ý nghĩa.
- Fine-tune nhẹ XTTS v2 với 6 đến 10 phút pseudo-speech để ổn định thanh điệu
- Khi nào cần fine-tune:
- Zero-shot từ tham chiếu tốt nhất vẫn sai thanh đều khi câu dài 20 đến 30 âm tiết.
- RVC-to-speech cho tham chiếu đã giúp nhưng vẫn có rung không chủ đích trên dấu hỏi, ngã hoặc nuốt dấu cuối câu.
- Chuẩn bị dữ liệu:
- Gom 6 đến 10 phút pseudo-speech tiếng Việt trung tính. Ưu tiên câu đa dạng âm tiết, có đủ 6 thanh, không reverb, không background.
- Giữ đồng nhất kênh, micro, môi trường. Cắt mỗi clip 3 đến 8 giây để huấn luyện ổn định và tránh mode collapse theo trường năng lượng dài.
- Chuẩn hóa text NFC, loại ký tự lạ, đảm bảo cặp audio-text khớp chặt.
- Thiết lập huấn luyện thận trọng để tránh metallic và rung formant:
- Freeze đa số backbone nếu công cụ cho phép hoặc dùng adapter nhẹ. Mục tiêu là điều chỉnh điều kiện giọng và mapping tiếng Việt, không viết lại prosody tổng quát.
- Learning rate thấp khoảng 1e-5 đến 5e-5, batch nhỏ. Bắt đầu ở 1e-5 nếu dữ liệu dưới 8 phút.
- Không dùng augmentation làm méo phổ như reverb hay time-stretch. Chuẩn hóa loudness vừa đủ, không nén động.
- Theo dõi validation loss theo từng epoch hoặc mỗi 200 đến 500 bước. Lưu checkpoint tốt nhất theo val loss, đồng thời nghe tay 6 đến 10 câu tiêu chuẩn sau mỗi mốc.
- Tiêu chí dừng sớm:
- Val loss không cải thiện có ý nghĩa 3 lần kiểm tra liên tiếp.
- Nghe tay xuất hiện metallic ở nguyên âm dài hoặc cảm giác phập phồng formant khi kéo trường độ, dù val loss còn giảm nhẹ. Dừng ngay và chọn checkpoint trước đó.
- Mốc dung lượng và đánh đổi:
- 2 phút: chỉ dùng để chứng minh khả thi một vòng. Dễ thiếu phủ thanh, phải train gắng sức dễ sinh metallic. Không khuyến nghị cho bản demo nghiêm túc.
- 6 đến 8 phút: mốc khởi đầu hợp lý nếu pseudo-speech sạch và phân bố thanh đều. Thường đã giảm thấy rõ lỗi hỏi-ngã và bớt rung không chủ đích.
- 10 phút: mức an toàn cao hơn để ổn định thanh trên câu dài, ít rủi ro phải đẩy LR hay số bước lên quá mức.
Cách ra quyết định đi tiếp hay dừng ở mỗi tầng
- Sau zero-shot:
- Pass nếu 80 đến 90 phần trăm câu test nghe đúng thanh theo tai nghe và không có rung không chủ đích kéo dài. Dừng lộ trình tại đây.
- Fail hoặc không chắc: chuyển sang RVC-to-speech để tạo tham chiếu pseudo-speech 30 đến 120 giây rồi thử zero-shot lại. Nếu vẫn fail, chuẩn bị tập 6 đến 10 phút cho fine-tune.
- Sau RVC-to-speech:
- Nếu tham chiếu pseudo-speech 30 đến 60 giây đã cho kết quả vượt mốc chấp nhận, bạn không cần fine-tune. Nếu còn sai thanh trên câu dài, sang bước fine-tune nhẹ.
- Sau fine-tune:
- Kiểm thử lại bộ câu 6 thanh, thêm đoạn 25 đến 35 âm tiết. Nếu không còn lặp hoặc nuốt âm, thanh điệu rõ, giữ preset Tin tức, Kể chuyện, Trò chuyện hoạt động ổn thì chốt voice profile.
Mẹo thực dụng để tránh lùi chất lượng khi đi từ RVC sang fine-tune
- Chỉ đưa vào tập pseudo-speech những câu nghe mượt, F0 không rung sâu. Loại những câu mà RVC làm xát phụ âm quá mức hoặc chói sibilant.
- Nếu phải transpose, giữ trong ±3 bán cung. Khi phải vượt mức này, ưu tiên sinh lại câu nguồn bằng TTS trung tính ở cao độ phù hợp rồi mới convert RVC.
- Trước khi train, synth thử 1 phút bằng chính XTTS v2 zero-shot với tham chiếu pseudo-speech dự kiến. Nếu đã thấy metallic hoặc sai thanh, quay lại làm sạch dữ liệu thay vì kỳ vọng fine-tune sẽ sửa.
Kiểm soát kiểu đọc: preset Tin tức, Kể chuyện, Trò chuyện
Ở giai đoạn synth, mình điều khiển “kiểu đọc” theo hai lớp: lớp văn bản bằng chấm câu để gợi nhịp dừng và lớp tham số suy luận lengthscale cùng temperature để kiểm soát tốc độ và mức biến thiên. Nếu bản bạn cài có mở thêm tham số topp, dùng như gia vị nhẹ cho độ linh hoạt.
Cách soạn câu để gợi nhịp trước khi synth
- Tin tức: câu ngắn 12 đến 20 âm tiết, dùng dấu phẩy chia mệnh đề dữ kiện. Tránh cảm thán, giữ cấu trúc SVO rõ ràng.
- Kể chuyện: câu dài hơn 18 đến 30 âm tiết, dùng phẩy và dấu ba chấm để thở nhịp, xuống dòng khi chuyển cảnh.
- Trò chuyện: câu rất ngắn 8 đến 16 âm tiết, thêm hạt đệm tự nhiên như “ờ”, “à” vừa phải, phẩy thưa.
- Dấu gợi nhịp hữu ích: phẩy, chấm phẩy, chấm, dấu ba chấm. Không rải quá dày vì sẽ xé nhịp thành từng mẩu rời.
Preset tham số có thể tái lập
- Tin tức: length_scale = 0.94, temperature = 0.55. Nhịp dứt khoát, ít biến thiên.
- Kể chuyện: length_scale = 1.08, temperature = 0.65. Nhịp chậm hơn, mở độ mềm.
- Trò chuyện: length_scale = 1.00, temperature = 0.60. Tự nhiên, không cố nhấn.
- Nếu server hỗ trợ, thử top_p = 0.9 để bớt đơn điệu khi temperature thấp. Nếu không được chấp nhận, bỏ qua tham số này.
Ví dụ chạy XTTS v2 với thư viện preset và tự chèn ngắt 200 ms giữa mệnh đề. Ý: chia văn bản theo phẩy, synth từng mệnh đề với preset và ghép im lặng 200 ms để giữ nhịp tự nhiên mà không phá thanh điệu vì điểm cắt trùng ranh giới mệnh đề.
# Python 3.10+. Yêu cầu: coqui-ai TTS, numpy, soundfile
import re
import numpy as np
import soundfile as sf
from TTS.api import TTS
import inspect
# Model và tham chiếu
tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to("cuda")
ref_wav = "ref_wpe.wav" # tham chiếu đã chuẩn bị
# Thư viện preset
PRESETS = {
"tin_tuc": {"length_scale": 0.94, "temperature": 0.55, "top_p": 0.9},
"ke_chuyen": {"length_scale": 1.08, "temperature": 0.65, "top_p": 0.9},
"tro_chuyen":{"length_scale": 1.00, "temperature": 0.60, "top_p": 0.9},
}
# Lọc tham số an toàn theo chữ ký hàm tts() để không lỗi nếu top_p không có
SIG = inspect.signature(tts.tts)
def safe_kwargs(preset: dict):
return {k: v for k, v in preset.items() if k in SIG.parameters}
def synth_with_clause_pauses(text: str, preset_name: str, out_path: str, pause_ms: int = 200):
preset = PRESETS[preset_name]
kw = safe_kwargs(preset)
# Chia theo phẩy và chấm phẩy, giữ dấu để gợi nhịp
parts = re.split(r'([,;])', text)
sr = tts.synthesizer.output_sample_rate
pause = np.zeros(int(sr * pause_ms / 1000.0), dtype=np.float32)
chunks = []
# Gom lại từng mệnh đề kèm dấu theo cặp [đoạn][dấu]
for i in range(0, len(parts), 2):
seg = parts[i].strip()
if not seg:
continue
punct = parts[i+1] if i + 1 < len(parts) else ""
seg_text = seg + (punct if punct in {",", ";"} else "")
audio = tts.tts(
text=seg_text,
language="vi",
speaker_wav=ref_wav,
**kw
)
# audio là np.float32 [-1, 1]
chunks.append(audio.astype(np.float32))
# Chèn khoảng lặng giữa mệnh đề, trừ khi là cuối câu
if punct in {",", ";"}:
chunks.append(pause.copy())
# Nối mảng và ghi file
wav = np.concatenate(chunks) if chunks else np.zeros(1, dtype=np.float32)
sf.write(out_path, wav, sr)
return out_path
# Ví dụ văn bản cho 3 kiểu đọc
text_news = "Bộ Giao thông vận tải vừa ban hành kế hoạch mới, tập trung vào an toàn đường bộ, và giảm ùn tắc tại các đô thị lớn."
text_story = "Trời đổ mưa từ cuối chiều, gió lùa qua hiên nhà, và mùi đất ẩm khiến ký ức cũ bỗng ùa về... Cậu dừng lại trước ngõ nhỏ, lắng nghe nhịp mưa rơi."
text_chat = "Ờ, mình thấy lịch hôm nay khá dày, hay là dời cuộc họp sang chiều mai, cho thoải mái hơn nhé."
synth_with_clause_pauses(text_news, "tin_tuc", "news.wav", pause_ms=200)
synth_with_clause_pauses(text_story, "ke_chuyen", "story.wav", pause_ms=220)
synth_with_clause_pauses(text_chat, "tro_chuyen", "chat.wav", pause_ms=180)
print("Wrote: news.wav, story.wav, chat.wav")
Gợi ý chỉnh nhịp nhanh bằng dấu câu trước khi chạy
- Tin tức: “Thời tiết miền Bắc chuyển lạnh, mưa xuất hiện cục bộ, nhiệt độ thấp nhất 16 độ.” Tối đa 2 dấu phẩy mỗi câu.
- Kể chuyện: “Cô khép nhẹ cánh cửa, rồi quay lại bàn làm việc, nơi cuốn sổ tay vẫn mở, và trang cuối còn dang dở…” Dùng dấu ba chấm cho ngắt mềm.
- Trò chuyện: “À, để mình kiểm tra lại nhé. Nếu kịp, mình gửi bạn trước 5 giờ.”
Ngưỡng mình thấy an toàn cho tiếng Việt để không méo thanh điệu
- length_scale trong dải 0.90 đến 1.12. Dưới 0.88 dễ cụt âm tiết, trên 1.15 dễ kéo đuôi nguyên âm.
- temperature trong dải 0.5 đến 0.7. Cao hơn tăng rủi ro rung không chủ đích.
- Pauses hậu kỳ 150 đến 250 ms ở ranh mệnh đề. Không chèn giữa từ hay giữa âm tiết. Cắt sai vị trí có thể làm đứt contour thanh hỏi và ngã.
Nếu muốn chia nhỏ theo dấu chấm để kiểm soát lặp ở câu dài, áp dụng cùng hàm synthwithclausepauses với regex tách theo chấm và phẩy, đặt pausems 200 mặc định. Cách này giữ nhịp tự nhiên vì bạn tôn trọng biên mệnh đề, thêm khoảng lặng ngắn nên không phá đường F0 của từng âm tiết.
Thiết kế ứng dụng PC: module hóa, API nội bộ và web UI
Mình tách app thành các mô-đun rõ ràng để bạn thay thế từng phần mà không đụng toàn bộ hệ thống, giữ được độ trễ thấp và dễ quan sát. Dịch vụ inference XTTS v2 chạy local là lõi, bao quanh là API nội bộ, cache theo profile và web UI tối giản để nhập văn bản, chọn preset, phát thử.
Các khối mô-đun
- Inference service: tiến trình Python nạp XTTS v2 vào GPU, giữ nóng model và VRAM. Nhận text tiếng Việt, tham chiếu voice profile, preset và trả về audio.
- API nội bộ: FastAPI hoặc Flask, exposed trên localhost. Cung cấp endpoint quản lý profile, upload file dài có progress, synth theo preset, và logging thời gian theo từng bước.
- Bộ nhớ cache theo voice profile: ánh xạ profile_id sang file tham chiếu đã chuẩn hóa và metadata. Nếu bản XTTS v2 bạn cài không nhận embedding trực tiếp, cache vẫn có giá trị vì tránh I/O và tiền xử lý lặp lại. Khi có bản cho phép truyền conditioning tensor, mở thêm nhánh cache embedding trong RAM của tiến trình.
- Cơ chế upload dài có progress: chia file tham chiếu thành chunk 4 MB, hiện thanh tiến độ trên UI, ghép lại phía server, tính SHA-256 để kiểm tra toàn vẹn rồi đăng ký thành profile.
- Web UI tối giản: form chọn profile, nhập văn bản, chọn preset Tin tức, Kể chuyện, Trò chuyện, nút phát thử. Tập trung phản hồi nhanh, kèm hiển thị thời gian tổng hợp và RTF ước lượng.
- Logging và cấu hình: log JSONL thời gian upload, trích tham chiếu, TTS, vocoder, tổng end-to-end, cùng preset và profile_id. Cho phép bật tắt log mức DEBUG qua biến môi trường.
Ví dụ API nội bộ bằng FastAPI, đủ để chạy demo local Giải thích ngắn: server nạp XTTS v2, nhận upload theo chunk 4 MB, tạo profile, tổng hợp audio từ text + preset, trả về WAV và log thời gian.
# app.py - FastAPI server cho XTTS v2 demo
# Yêu cầu: Python 3.10+, pip install "TTS>=0.22.0" fastapi uvicorn soundfile numpy
import os, io, json, time, hashlib, shutil, uuid
from pathlib import Path
from typing import Dict
import numpy as np
import soundfile as sf
from fastapi import FastAPI, UploadFile, File, Query, HTTPException
from fastapi.responses import StreamingResponse, JSONResponse
from TTS.api import TTS
import torch
DATA_DIR = Path("./data")
PROFILES_DIR = DATA_DIR / "profiles"
UPLOADS_DIR = DATA_DIR / "uploads"
LOGS_DIR = DATA_DIR / "logs"
for d in [PROFILES_DIR, UPLOADS_DIR, LOGS_DIR]:
d.mkdir(parents=True, exist_ok=True)
device = "cuda" if torch.cuda.is_available() else "cpu"
tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(device)
PRESETS = {
"tin_tuc": {"length_scale": 0.94, "temperature": 0.55},
"ke_chuyen": {"length_scale": 1.08, "temperature": 0.65},
"tro_chuyen":{"length_scale": 1.00, "temperature": 0.60},
}
# Cache profile: id -> {"path": str, "sha256": str}
PROFILES: Dict[str, Dict] = {}
app = FastAPI(title="pc-tts-local")
def log_event(event: dict):
event["ts"] = time.time()
with (LOGS_DIR / "infer.jsonl").open("a", encoding="utf-8") as f:
f.write(json.dumps(event, ensure_ascii=False) + "\n")
@app.get("/presets")
def list_presets():
return {"presets": list(PRESETS.keys())}
@app.post("/profiles/upload_init")
def upload_init():
upload_id = str(uuid.uuid4())
(UPLOADS_DIR / upload_id).mkdir(parents=True, exist_ok=True)
return {"upload_id": upload_id, "chunk_size": 4 * 1024 * 1024}
@app.put("/profiles/upload_chunk/{upload_id}")
async def upload_chunk(upload_id: str, index: int = Query(..., ge=0), total: int = Query(..., gt=0), file: UploadFile = File(...)):
folder = UPLOADS_DIR / upload_id
if not folder.exists():
raise HTTPException(404, "upload_id not found")
part_path = folder / f"{index:06d}.part"
data = await file.read()
with part_path.open("wb") as f:
f.write(data)
return {"ok": True, "index": index, "received": len(data)}
@app.post("/profiles/upload_finalize/{upload_id}")
def upload_finalize(upload_id: str, filename: str = "ref.wav"):
folder = UPLOADS_DIR / upload_id
if not folder.exists():
raise HTTPException(404, "upload_id not found")
parts = sorted(folder.glob("*.part"))
if not parts:
raise HTTPException(400, "no parts")
out_path = PROFILES_DIR / f"{uuid.uuid4()}_{filename}"
with out_path.open("wb") as out_f:
for p in parts:
with p.open("rb") as pf:
shutil.copyfileobj(pf, out_f)
# Tính SHA-256 để xác minh
h = hashlib.sha256()
with out_path.open("rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
profile_id = out_path.stem
PROFILES[profile_id] = {"path": str(out_path), "sha256": h.hexdigest()}
shutil.rmtree(folder, ignore_errors=True)
return {"profile_id": profile_id, "path": str(out_path), "sha256": PROFILES[profile_id]["sha256"]}
@app.get("/profiles")
def list_profiles():
return {"profiles": [{"id": pid, "path": v["path"]} for pid, v in PROFILES.items()]}
@app.post("/synthesize")
async def synthesize(text: str, profile_id: str, preset: str):
if preset not in PRESETS:
raise HTTPException(400, "invalid preset")
if profile_id not in PROFILES:
raise HTTPException(404, "profile not found")
ref_path = PROFILES[profile_id]["path"]
cfg = PRESETS[preset]
t0 = time.perf_counter()
# Tổng hợp
audio = tts.tts(
text=text,
language="vi",
speaker_wav=ref_path,
**cfg
)
# audio là np.float32 [-1,1]
sr = tts.synthesizer.output_sample_rate
t1 = time.perf_counter()
# Ghi log độ trễ
dur = len(audio) / sr
infer_s = t1 - t0
rtf = infer_s / max(dur, 1e-6)
log_event({"event": "synthesize", "profile_id": profile_id, "preset": preset,
"text_len": len(text), "duration_s": round(dur, 3),
"infer_s": round(infer_s, 3), "rtf": round(rtf, 3)})
# Trả về WAV dạng stream
buf = io.BytesIO()
sf.write(buf, audio.astype(np.float32), sr, format="WAV", subtype="PCM_16")
buf.seek(0)
headers = {
"X-RTF": f"{rtf:.3f}",
"X-Duration": f"{dur:.3f}"
}
return StreamingResponse(buf, media_type="audio/wav", headers=headers)
Web UI tối giản để upload tham chiếu có progress, nhập text, chọn preset và phát thử Giải thích ngắn: HTML tĩnh gọi API bằng fetch. Upload chia chunk 4 MB, hiển thị tiến độ, sau đó gọi synth và phát bằng thẻ audio.
<!-- ui.html - mở bằng file:// hoặc phục vụ tĩnh cùng server -->
<!doctype html>
<html lang="vi"><meta charset="utf-8"><title>PC TTS Local</title>
<body>
<h3>Tạo voice profile</h3>
<input type="file" id="refFile" accept=".wav,.flac" />
<button id="btnUpload">Upload</button>
<div id="upStatus"></div>
<h3>Synthesize</h3>
<select id="preset">
<option value="tin_tuc">Tin tức</option>
<option value="ke_chuyen">Kể chuyện</option>
<option value="tro_chuyen">Trò chuyện</option>
</select>
<select id="profile"></select>
<br/>
<textarea id="text" rows="4" cols="60">Thời tiết miền Bắc chuyển lạnh, mưa xuất hiện cục bộ, nhiệt độ thấp nhất 16 độ.</textarea><br/>
<button id="btnSynth">Phát thử</button>
<div id="synStatus"></div>
<audio id="player" controls></audio>
<script>
const api = "http://127.0.0.1:8000";
let currentProfile = null;
async function refreshProfiles() {
const res = await fetch(`${api}/profiles`);
const data = await res.json();
const sel = document.getElementById("profile");
sel.innerHTML = "";
data.profiles.forEach(p => {
const opt = document.createElement("option");
opt.value = p.id; opt.textContent = p.id;
sel.appendChild(opt);
});
}
refreshProfiles();
document.getElementById("btnUpload").onclick = async () => {
const f = document.getElementById("refFile").files[0];
if (!f) return;
const init = await fetch(`${api}/profiles/upload_init`, {method: "POST"});
const {upload_id, chunk_size} = await init.json();
const status = document.getElementById("upStatus");
const total = Math.ceil(f.size / chunk_size);
for (let i = 0; i < total; i++) {
const start = i * chunk_size;
const end = Math.min(start + chunk_size, f.size);
const blob = f.slice(start, end);
const form = new FormData();
form.append("file", blob, `chunk_${i}`);
await fetch(`${api}/profiles/upload_chunk/${upload_id}?index=${i}&total=${total}`, {method: "PUT", body: form});
status.textContent = `Đang tải: ${((end / f.size) * 100).toFixed(1)}%`;
}
const fin = await fetch(`${api}/profiles/upload_finalize/${upload_id}?filename=${encodeURIComponent(f.name)}`, {method: "POST"});
const info = await fin.json();
status.textContent = `Hoàn tất profile: ${info.profile_id}`;
await refreshProfiles();
};
document.getElementById("btnSynth").onclick = async () => {
const text = document.getElementById("text").value;
const preset = document.getElementById("preset").value;
const profile = document.getElementById("profile").value;
const params = new URLSearchParams({text, preset, profile_id: profile});
const res = await fetch(`${api}/synthesize?${params.toString()}`, {method: "POST"});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const rtf = res.headers.get("X-RTF");
const dur = res.headers.get("X-Duration");
document.getElementById("synStatus").textContent = `RTF ~ ${rtf}, duration ${dur}s`;
const player = document.getElementById("player");
player.src = url; player.play();
};
</script>
</body></html>
Ghi chú chạy như service trên Windows và lệnh systemd trên Linux
- Windows 11 với NSSM cho ổn định dịch vụ
- Cài NSSM, rồi đăng ký service chạy uvicorn
- Lệnh mẫu:
nssm install pc-tts "C:\Python311\python.exe" -m uvicorn app:app --host 0.0.0.0 --port 8000
nssm set pc-tts AppDirectory C:\path\to\your\project
nssm start pc-tts
- Nếu dùng PowerShell New-Service, tạo file run-uvicorn.bat kích hoạt venv rồi gọi uvicorn, đặt StartupType Automatic.
- Linux với systemd
- Tạo unit file
# /etc/systemd/system/pc-tts.service
[Unit]
Description=PC TTS Local - XTTS v2
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/pc-tts
ExecStart=/opt/pc-tts/.venv/bin/uvicorn app:app --host 0.0.0.0 --port 8000
Restart=on-failure
Environment=CUDA_VISIBLE_DEVICES=0
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
- Kích hoạt
sudo systemctl daemon-reload
sudo systemctl enable --now pc-tts
sudo systemctl status pc-tts
Gợi ý triển khai cache profile và logging
- Lưu profile ở thư mục data/profiles, đặt tên gắn profile_id và checksum để tái sử dụng. Nếu bản XTTS v2 của bạn hỗ trợ truyền conditioning đã tính, thêm lớp cache RAM trên tiến trình và vô hiệu hóa tái tính ở mỗi lượt synth. Nếu không, tái sử dụng file tham chiếu đã chuẩn hóa vẫn giúp giảm IO và giữ ổn định đầu vào.
- Bật warm-up ngay sau khi khởi động service bằng cách synth 1 câu ngắn 2 đến 3 giây để hâm nóng kernel GPU. Log thời gian và RTF qua header HTTP như ví dụ, đồng thời ghi JSONL để bạn phân tích theo p50 p95.
Hạ tầng và tối ưu hiệu năng
Mình chốt cấu hình tác nghiệp cho máy PC có GPU NVIDIA 8 GB VRAM để đạt độ trễ dễ dùng và ổn định. Trọng tâm là chạy FP16, batch 1, warm-up ngay khi khởi động, bật cudnn benchmark, suy luận theo chunk 4 đến 6 giây, cache phần tham chiếu khi có thể, ước lượng RTF để giám sát, chuẩn bị fallback CPU chậm hơn, và quy trình cứu OOM theo thứ tự an toàn. Minh họa chính trên Windows 11, các lệnh Python giống nhau trên Linux.
Thiết lập runtime khuyến nghị cho PyTorch và XTTS v2
- Dùng FP16 với autocast và no_grad để giảm VRAM và tăng throughput.
- Batch size = 1 cho TTS autoregressive – tăng batch không giảm độ trễ đơn lẻ.
- Warm-up 1 lần bằng câu ngắn để JIT chọn kernel và cấp phát bộ nhớ.
- Bật cudnn benchmark để chọn thuật toán tối ưu cho kích thước tensor thực tế.
- Đảm bảo mô hình và vocoder cùng nằm trên GPU bằng tts.to("cuda").
Ví dụ cấu hình và tổng hợp theo chunk 4 đến 6 giây, có đo RTF Giải thích: tách văn bản theo dấu câu và giới hạn ký tự tương đương 4 đến 6 giây. Tổng hợp tuần tự từng đoạn, ghép lại, đo RTF toàn phiên.
# runtime_opt.py
import re, time, numpy as np, torch
from TTS.api import TTS
import soundfile as sf
device = "cuda" if torch.cuda.is_available() else "cpu"
tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(device)
torch.backends.cudnn.benchmark = True
torch.set_grad_enabled(False)
def chunk_text_vi(text: str, max_chars: int = 150):
# Ưu tiên tách theo ., !, ?, ;, , rồi cắt mềm theo độ dài
sents = re.split(r"([\.!\?;])", text)
merged = []
for i in range(0, len(sents), 2):
seg = sents[i].strip()
punc = sents[i+1] if i+1 < len(sents) else ""
if seg:
merged.append((seg + (punc or "")).strip())
out, buf = [], ""
for sent in merged:
if len(buf) + 1 + len(sent) <= max_chars:
buf = sent if not buf else f"{buf} {sent}"
else:
if buf: out.append(buf)
buf = sent
if buf: out.append(buf)
return out
def synth_vi(text, ref_wav, length_scale=1.0, temperature=0.6):
chunks = chunk_text_vi(text, max_chars=150)
audio_out = []
sr = tts.synthesizer.output_sample_rate
t0 = time.perf_counter()
for idx, ch in enumerate(chunks):
# warm-up tự nhiên: chunk đầu sẽ kích hoạt kernel
with torch.inference_mode(), torch.cuda.amp.autocast(enabled=(device=="cuda"), dtype=torch.float16):
y = tts.tts(
text=ch,
language="vi",
speaker_wav=ref_wav,
length_scale=length_scale,
temperature=temperature
)
audio_out.append(y.astype(np.float32))
# chèn ngắt nhẹ 0.18 đến 0.22 giây giữa các chunk
if idx != len(chunks) - 1:
audio_out.append(np.zeros(int(0.2 * sr), dtype=np.float32))
ycat = np.concatenate(audio_out) if audio_out else np.zeros(1, dtype=np.float32)
t1 = time.perf_counter()
dur = len(ycat) / sr
rtf = (t1 - t0) / max(dur, 1e-6)
return ycat, sr, rtf
if __name__ == "__main__":
ref = "data/profiles/example_ref.wav" # đường dẫn mẫu hoàn chỉnh
text = "Thời tiết miền Bắc chuyển lạnh, mưa xuất hiện cục bộ. Nhiệt độ thấp nhất khoảng mười sáu độ."
# Warm-up ngắn
_ = synth_vi("Xin chào", ref)
# Chạy chính
audio, sr, rtf = synth_vi(text, ref, length_scale=0.98, temperature=0.6)
print(f"RTF ~ {rtf:.3f}, duration {len(audio)/sr:.2f}s")
sf.write("out.wav", audio, sr, subtype="PCM_16")
Cache embedding tham chiếu và giữ trên GPU khi có thể
- Nếu bản Coqui TTS bạn cài đặt có phương thức lấy conditioning latents từ file tham chiếu và cho phép tái dùng ở nhiều lượt suy luận, hãy trích một lần khi nạp profile và giữ tensor đó trên GPU. Nhờ vậy bạn bỏ qua mã hóa lại tham chiếu cho mỗi câu, giảm vài chục mili giây và tránh copy host-to-device lặp lại.
- Nếu API bạn dùng chỉ nhận speaker_wav, vẫn nên cache đường dẫn và file đã chuẩn hóa trong RAM hoặc trên SSD và tái sử dụng cho mọi lượt synth của profile đó.
Ước lượng RTF kỳ vọng và cách đo
- Với GPU 8 GB thế hệ tương đương RTX 2060 Super đến 4060, FP16, batch 1, chunk 4 đến 6 giây, warm-up và vocoder chạy trên GPU, bạn nên kỳ vọng RTF cỡ 0.35 đến 0.7 cho câu tổng dài khoảng 20 giây. Con số thực tế phụ thuộc driver, CUDA, phiên bản PyTorch và mức tải nền.
- Cách đo đáng tin: tổng hợp 5 lần cùng một văn bản 20 đến 25 âm tiết theo preset bạn sẽ dùng, bỏ lượt đầu để loại warm-up, lấy trung vị p50 và p95. Nếu p95 vượt 0.7, rút chunk xuống tầm 120 ký tự, kiểm tra lại.
Fallback CPU khi không có GPU hoặc GPU bận
- Chuyển tts.to("cpu") và tắt autocast. RTF sẽ lớn hơn đáng kể – thường vượt 1 – nên bạn cần chia nhỏ chunk hơn nữa, ví dụ 80 đến 100 ký tự. Chiến lược dùng được là phát streaming theo chunk vừa synth xong để người dùng không phải chờ toàn câu.
Quy trình xử lý OOM và giữ vocoder trên GPU
- Kiểm tra đúng thiết bị: đảm bảo toàn bộ mô hình đã to("cuda") trước khi gọi tts.tts. Nếu phần vocoder bị đẩy sang CPU do thiếu VRAM, độ trễ sẽ tăng đột biến – ưu tiên giữ vocoder trên GPU.
- Giảm yêu cầu bộ nhớ theo thứ tự:
- Rút ngắn chunk – từ 150 ký tự về 120, rồi 100. Tránh chunk sinh âm vượt 6 đến 8 giây.
- Đảm bảo FP16 đã bật với autocast và no_grad.
- Làm sạch biến tạm và gọi torch.cuda.empty_cache() giữa các lượt dài.
- Đóng các tiến trình khác đang dùng GPU và cố giữ tối thiểu 1 đến 1.5 GB VRAM trống cho buffer runtime.
- Nếu vẫn OOM, bạn có thể đẩy riêng phần text dài sang nhiều lượt synth nhỏ hơn rồi ghép lại – đổi lấy thêm một chút khoảng lặng 200 ms ở ranh giới để tránh click.
Mẹo hệ thống nhỏ nhưng hiệu quả
- Windows 11: đặt Power plan High performance và trong NVIDIA Control Panel chọn Prefer maximum performance cho ứng dụng Python để tránh clock tụt.
- Linux: bật Persistence Mode cho driver NVIDIA nếu máy chủ chạy lâu dài để tránh cold-start VRAM allocation.
Tóm nhanh checklist an toàn cho GPU 8 GB
- FP16 + no_grad + autocast.
- cudnn.benchmark = True, warm-up 1 lượt.
- Batch 1, chunk 4 đến 6 giây – khoảng 120 đến 160 ký tự, có ngắt 200 ms giữa chunk.
- Cache tham chiếu – và embedding nếu API cho phép – giữ trên GPU khi còn chỗ.
- Đo và log RTF p50, p95. Nếu p95 > 0.7, rút chunk và kiểm tra vocoder đang chạy trên GPU. Nếu hết cách, bật fallback CPU và phát theo chunk.
Lộ trình dữ liệu và độ trễ đầu cuối
Bạn cần một đường đi dữ liệu rõ ràng từ lúc chọn file tham chiếu đến lúc phát âm, cùng ngân sách độ trễ cho từng chặng. Mục tiêu là chấp nhận tốn thêm thời gian truyền nếu tham chiếu dài hơn, nhưng UI vẫn mượt: luôn có progress, mọi xử lý nặng như trích embedding chạy bất đồng bộ, và synth theo chunk để phát nhanh đoạn đầu.
Lộ trình dữ liệu end-to-end
- Chọn tham chiếu:
- Cùng máy: ưu tiên WAV PCM 16-bit để tránh decode, đọc NVMe cực nhanh.
- Qua mạng LAN hoặc Internet: dùng FLAC lossless, mức nén vừa phải để giảm dung lượng truyền.
- Tạo phiên upload:
- Gọi init để cấp upload_id, nhận cấu hình chunk 4 MB.
- Truyền file theo từng chunk 4 MB trên HTTP/1.1 keep-alive hoặc HTTP/2 để tái dùng kết nối.
- Ghép và kiểm tra toàn vẹn:
- Sau khi ghép đủ chunk, tính SHA-256 toàn file và đối chiếu với hash client gửi trước đó. Sai là loại ngay, không đưa vào pipeline.
- Tiền xử lý nhẹ và tiền trích embedding bất đồng bộ:
- Ngay khi xác thực xong, đẩy một tác vụ nền trích conditioning latents hoặc ít nhất chuẩn hóa và cache file tham chiếu. UI hiển thị trạng thái “Đang chuẩn bị giọng”.
- Tổng hợp và phát:
- Khi embedding sẵn sàng, synth theo chunk văn bản 4 đến 6 giây, phát nối tiếp để người dùng nghe ngay phần đầu ra thay vì đợi hết câu.
Ví dụ server FastAPI tối thiểu cho upload chunk 4 MB, giữ kết nối và băm SHA-256
# server_upload.py
import os, io, hashlib, uuid, asyncio
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
UPLOAD_ROOT = "uploads"
PROFILE_ROOT = "profiles"
os.makedirs(UPLOAD_ROOT, exist_ok=True)
os.makedirs(PROFILE_ROOT, exist_ok=True)
app = FastAPI()
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
# Bộ nhớ tạm quản lý tiến độ
SESS = {} # upload_id -> {"size": int, "sha256": str, "path": str, "received": int}
@app.post("/api/upload/init")
async def upload_init(filename: str = Form(...), size_bytes: int = Form(...), sha256: str = Form(...)):
upload_id = str(uuid.uuid4())
tmp_path = os.path.join(UPLOAD_ROOT, f"{upload_id}.part")
SESS[upload_id] = {"size": size_bytes, "sha256": sha256, "path": tmp_path, "received": 0}
# Tạo file rỗng
open(tmp_path, "wb").close()
return {"upload_id": upload_id, "chunk_bytes": 4 * 1024 * 1024}
@app.put("/api/upload/chunk")
async def upload_chunk(upload_id: str = Form(...), index: int = Form(...), data: UploadFile = File(...)):
if upload_id not in SESS:
raise HTTPException(404, "upload_id not found")
sess = SESS[upload_id]
chunk = await data.read()
if len(chunk) == 0:
raise HTTPException(400, "empty chunk")
# Ghi nối đuôi theo index cố định 4 MB
with open(sess["path"], "r+b") as f:
f.seek(index * 4 * 1024 * 1024)
f.write(chunk)
sess["received"] += len(chunk)
pct = min(100, int(sess["received"] * 100 / max(1, sess["size"])))
return {"received": sess["received"], "percent": pct}
async def _pre_extract_embedding(profile_path: str):
# Tác vụ nền: chuẩn bị để synth nhanh hơn
# Không giả định API nào luôn có sẵn - thử và fall back
try:
from TTS.api import TTS
tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2")
# Nhiều bản XTTS v2 có API này - nếu không có, đoạn try sẽ fail và ta bỏ qua
if hasattr(tts, "get_conditioning_latents"):
_ = tts.get_conditioning_latents(audio_path=profile_path)
except Exception:
pass
@app.post("/api/upload/finalize")
async def upload_finalize(upload_id: str = Form(...), original_name: str = Form(...)):
if upload_id not in SESS:
raise HTTPException(404, "upload_id not found")
sess = SESS.pop(upload_id)
if sess["received"] != sess["size"]:
raise HTTPException(400, "size mismatch")
# Tính SHA-256 toàn file và so với client
h = hashlib.sha256()
with open(sess["path"], "rb") as f:
for buf in iter(lambda: f.read(4 * 1024 * 1024), b""):
h.update(buf)
if h.hexdigest().lower() != sess["sha256"].lower():
os.remove(sess["path"])
raise HTTPException(400, "sha256 mismatch")
# Lưu thành profile, không đổi định dạng
profile_id = str(uuid.uuid4())
ext = os.path.splitext(original_name)[1].lower()
final_path = os.path.join(PROFILE_ROOT, f"{profile_id}{ext}")
os.replace(sess["path"], final_path)
# Tiền trích embedding bất đồng bộ
asyncio.create_task(_pre_extract_embedding(final_path))
return {"profile_id": profile_id, "profile_path": final_path, "status": "preparing"}
Ví dụ client web upload theo chunk 4 MB, có progress và HTTP/2 nếu server hỗ trợ
// client.js
async function sha256Hex(blob) {
const buf = await blob.arrayBuffer();
const hash = await crypto.subtle.digest("SHA-256", buf);
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, "0")).join("");
}
async function uploadReference(file) {
const hash = await sha256Hex(file);
const initForm = new FormData();
initForm.append("filename", file.name);
initForm.append("size_bytes", file.size.toString());
initForm.append("sha256", hash);
const initRes = await fetch("/api/upload/init", { method: "POST", body: initForm, keepalive: true });
const { upload_id, chunk_bytes } = await initRes.json();
const chunkSize = chunk_bytes; // 4 MB từ server
let offset = 0, index = 0;
while (offset < file.size) {
const slice = file.slice(offset, Math.min(offset + chunkSize, file.size));
const fd = new FormData();
fd.append("upload_id", upload_id);
fd.append("index", index.toString());
fd.append("data", slice, `chunk_${index}`);
await fetch("/api/upload/chunk", { method: "PUT", body: fd, keepalive: true });
offset += chunkSize;
index += 1;
const pct = Math.min(100, Math.floor((offset * 100) / file.size));
document.getElementById("progress").value = pct;
}
const finForm = new FormData();
finForm.append("upload_id", upload_id);
finForm.append("original_name", file.name);
const finRes = await fetch("/api/upload/finalize", { method: "POST", body: finForm, keepalive: true });
return await finRes.json(); // { profile_id, status: "preparing" }
}
Cách phân bổ và kiểm soát độ trễ
- Truyền tham chiếu:
- Cùng máy chọn WAV và bỏ qua upload mạng. Thời gian chủ yếu là I/O nội bộ.
- Qua mạng chọn FLAC. Với chunk 4 MB và keep-alive hoặc HTTP/2, bạn tận dụng băng thông mà không tăng tail latency do quá nhiều request nhỏ.
- Xác thực và chuẩn bị:
- SHA-256 toàn file chạy theo luồng đọc 4 MB, không cần giữ hết vào RAM. Tác vụ tiền trích embedding chạy nền, UI vẫn cho bạn nhập văn bản và chọn preset.
- Tổng hợp và phát:
- Ngay khi embedding sẵn sàng, synth theo chunk 4 đến 6 giây và phát nối tiếp. Trên GPU 8 GB, cách này giúp người nghe có âm thanh trong vài giây đầu, bất kể tham chiếu dài bao nhiêu.
Lưu ý thực hành
- Hiển thị progress phần trăm và thời gian ước lượng để người dùng chấp nhận tham chiếu dài. Khi dùng FLAC qua mạng, progress chính là byte gửi đi, không cần đợi decode.
- Nếu môi trường của bạn có API trích conditioning latents, hãy chạy ngay sau finalize rồi cache. Nếu không, tối thiểu hãy chuẩn hóa file tham chiếu và cache đường dẫn để mỗi lượt synth không phải đọc lại từ ổ chậm.
Dựng bản demo end-to-end
Đây là checklist thao tác để bạn dựng bản demo chạy local: từ chuẩn bị tham chiếu, chạy lệnh synth XTTS v2, sinh 3 preset Tin tức, Kể chuyện, Trò chuyện, đo thời gian và ghi log, lưu profile với thông số mặc định, đến chạy web UI và xác nhận nghe ra loa.
Các bước thực hiện
- Chuẩn bị thư mục dự án
- Tạo cấu trúc tối thiểu để tách hồ sơ giọng, file xuất và log:
mkdir -p demo_tts/{profiles,outputs,logs}
- Đặt một file tham chiếu giọng vào demotts/profiles, ví dụ profiles/ref01.wav. Ưu tiên WAV 16 kHz hoặc 22.05 kHz mono, sạch và ít vang.
- Kiểm thử nhanh XTTS v2 bằng lệnh một dòng
- Mục tiêu: sinh một file WAV tiếng Việt từ XTTS v2 để xác nhận môi trường và reference hợp lệ. Dùng tham số temperature để kiểm soát mức biến thiên, giữ 0.55 đến 0.65 cho tiếng Việt.
- Windows PowerShell:
cd demo_tts
python -c "from TTS.api import TTS; import torch; d='cuda' if torch.cuda.is_available() else 'cpu'; t=TTS('tts_models/multilingual/multi-dataset/xtts_v2').to(d); t.tts_to_file(text='Dự báo thời tiết hôm nay, Hà Nội có mưa rào rải rác.', speaker_wav='profiles/ref_01.wav', language='vi', file_path='outputs/quick_test_news.wav', temperature=0.55)"
- Linux bash:
cd demo_tts
python3 -c "from TTS.api import TTS; import torch; d='cuda' if torch.cuda.is_available() else 'cpu'; t=TTS('tts_models/multilingual/multi-dataset/xtts_v2').to(d); t.tts_to_file(text='Dự báo thời tiết hôm nay, Hà Nội có mưa rào rải rác.', speaker_wav='profiles/ref_01.wav', language='vi', file_path='outputs/quick_test_news.wav', temperature=0.55)"
- Nghe nhanh trên loa máy tính bằng FFmpeg ffplay
- Windows:
ffplay -v quiet -nodisp -autoexit outputs/quick_test_news.wav
- Linux:
ffplay -v quiet -nodisp -autoexit outputs/quick_test_news.wav
- Tạo script synth 3 preset và ghi log thời gian
- Script sau sinh 3 kiểu đọc Tin tức, Kể chuyện, Trò chuyện theo 3 cấu hình temperature, đo thời gian thực thi, tính RTF và ghi vào logs/tts.log. Bạn có thể đổi ref_path trỏ tới file tham chiếu của mình.
# demo_tts/synth_presets.py
import os, time, json, wave, contextlib, pathlib
from datetime import datetime
from TTS.api import TTS
import torch
BASE = pathlib.Path(__file__).resolve().parent
OUT = BASE / "outputs"
LOG = BASE / "logs" / "tts.log"
OUT.mkdir(exist_ok=True, parents=True)
LOG.parent.mkdir(exist_ok=True, parents=True)
# Hồ sơ tham chiếu và mặc định
profile = {
"id": "ref_01",
"ref_path": str(BASE / "profiles" / "ref_01.wav"),
"language": "vi",
"default_temperature": 0.60,
"note": "Giọng tham chiếu sạch, ít vang"
}
presets = {
"Tin_tuc": {"temperature": 0.55, "text": "Bản tin trưa nay, các tuyến đường trung tâm thông thoáng, trời nhiều mây, có khả năng mưa nhỏ."},
"Ke_chuyen": {"temperature": 0.65, "text": "Ngày ấy, trời vừa dịu nắng, cơn gió nhẹ thổi qua khu vườn... cậu bé khẽ mỉm cười, bước chậm lại."},
"Tro_chuyen": {"temperature": 0.60, "text": "Chào bạn, mình vừa hoàn tất bản demo. Bạn thử nghe đoạn này xem ổn chưa nhé."}
}
device = "cuda" if torch.cuda.is_available() else "cpu"
tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(device)
def synth_one(name, cfg, ref_path):
out_fp = OUT / f"demo_{name}.wav"
t0 = time.perf_counter()
tts.tts_to_file(
text=cfg["text"],
speaker_wav=ref_path,
language=profile["language"],
file_path=str(out_fp),
temperature=cfg["temperature"],
)
t1 = time.perf_counter()
with contextlib.closing(wave.open(str(out_fp), 'rb')) as wf:
frames = wf.getnframes()
rate = wf.getframerate()
dur = frames / float(rate)
synth_time = t1 - t0
rtf = synth_time / max(1e-6, dur)
log_line = {
"ts": datetime.utcnow().isoformat() + "Z",
"preset": name,
"temperature": cfg["temperature"],
"ref": str(ref_path),
"out": str(out_fp),
"audio_sec": round(dur, 3),
"synth_sec": round(synth_time, 3),
"rtf": round(rtf, 3)
}
with open(LOG, "a", encoding="utf-8") as f:
f.write(json.dumps(log_line, ensure_ascii=False) + "\n")
return out_fp
if __name__ == "__main__":
print(f"Using device: {device}")
ref = profile["ref_path"]
assert os.path.isfile(ref), f"Missing reference: {ref}"
# Lưu profile để tái dùng
with open(BASE / "profiles" / f"{profile['id']}.json", "w", encoding="utf-8") as f:
json.dump(profile, f, ensure_ascii=False, indent=2)
# Warm-up ngắn để ổn định thời gian
tts.tts_to_file(text="Xin chào.", speaker_wav=ref, language="vi", file_path=str(OUT / "_warmup.wav"), temperature=0.6)
for name, cfg in presets.items():
p = synth_one(name, cfg, ref)
print(f"OK {name}: {p}")
print(f"Log: {LOG}")
- Chạy script
- Windows PowerShell:
cd demo_tts
python .\synth_presets.py
ffplay -v quiet -nodisp -autoexit outputs/demo_Tin_tuc.wav
ffplay -v quiet -nodisp -autoexit outputs/demo_Ke_chuyen.wav
ffplay -v quiet -nodisp -autoexit outputs/demo_Tro_chuyen.wav
- Linux bash:
cd demo_tts
python3 ./synth_presets.py
ffplay -v quiet -nodisp -autoexit outputs/demo_Tin_tuc.wav
ffplay -v quiet -nodisp -autoexit outputs/demo_Ke_chuyen.wav
ffplay -v quiet -nodisp -autoexit outputs/demo_Tro_chuyen.wav
- Kiểm tra log thời gian
cat logs/tts.log
Mỗi dòng là một JSON ghi preset, thời lượng audio, thời gian synth, và RTF.
- Lưu và tái dùng profile giọng
- File profiles/ref_01.json đã được script trên tạo sẵn, dạng:
{
"id": "ref_01",
"ref_path": "C:/path/to/demo_tts/profiles/ref_01.wav",
"language": "vi",
"default_temperature": 0.6,
"note": "Giọng tham chiếu sạch, ít vang"
}
- Khi đổi file tham chiếu hoặc mặc định temperature, cập nhật JSON này, các script và UI sẽ đọc lại để giữ cấu hình đồng nhất.
- Chạy web UI local để synth theo preset và nghe ngay trên loa
- App dưới đây dùng Gradio, nạp XTTS v2, cho bạn chọn profile đã lưu hoặc upload tham chiếu mới, chọn preset và synth. UI phát audio ngay trong trình duyệt qua loa mặc định của máy.
# demo_tts/app_ui.py
import os, json, time, wave, contextlib, pathlib
import gradio as gr
from datetime import datetime
from TTS.api import TTS
import torch
BASE = pathlib.Path(__file__).resolve().parent
PROFILES = BASE / "profiles"
OUT = BASE / "outputs"
LOG = BASE / "logs" / "tts.log"
PROFILES.mkdir(exist_ok=True, parents=True)
OUT.mkdir(exist_ok=True, parents=True)
LOG.parent.mkdir(exist_ok=True, parents=True)
device = "cuda" if torch.cuda.is_available() else "cpu"
tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(device)
PRESETS = {
"Tin tức": {"temperature": 0.55, "hint": "Câu ngắn, dứt khoát, có dấu phẩy hợp lý."},
"Kể chuyện": {"temperature": 0.65, "hint": "Thêm dấu phẩy và dấu ba chấm ở mốc ý."},
"Trò chuyện": {"temperature": 0.60, "hint": "Tự nhiên, câu ngắn vừa, tránh cảm thán mạnh."}
}
def list_profiles():
items = []
for p in PROFILES.glob("*.json"):
try:
data = json.loads(p.read_text(encoding="utf-8"))
items.append((data.get("id", p.stem), str(p)))
except Exception:
continue
return items
def load_profile(profile_json_path):
if not profile_json_path or not os.path.isfile(profile_json_path):
return "", 0.6, "vi"
data = json.loads(open(profile_json_path, "r", encoding="utf-8").read())
return data.get("ref_path", ""), float(data.get("default_temperature", 0.6)), data.get("language", "vi")
def save_profile(profile_id, ref_path, default_temp, language):
pj = PROFILES / f"{profile_id}.json"
data = {"id": profile_id, "ref_path": ref_path, "default_temperature": float(default_temp), "language": language, "note": "Saved via UI"}
pj.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
return f"Saved {pj}", gr.update(choices=[x[1] for x in list_profiles()])
def synth(text, preset, ref_path, temperature, language):
assert os.path.isfile(ref_path), "Thiếu file tham chiếu hợp lệ"
out_fp = OUT / f"ui_{int(time.time())}.wav"
t0 = time.perf_counter()
tts.tts_to_file(text=text, speaker_wav=ref_path, language=language, file_path=str(out_fp), temperature=float(temperature))
t1 = time.perf_counter()
with contextlib.closing(wave.open(str(out_fp), 'rb')) as wf:
frames = wf.getnframes()
rate = wf.getframerate()
dur = frames / float(rate)
rtf = (t1 - t0) / max(1e-6, dur)
log_line = {
"ts": datetime.utcnow().isoformat() + "Z",
"source": "ui",
"preset": preset,
"temperature": float(temperature),
"ref": str(ref_path),
"out": str(out_fp),
"audio_sec": round(dur, 3),
"synth_sec": round(t1 - t0, 3),
"rtf": round(rtf, 3)
}
with open(LOG, "a", encoding="utf-8") as f:
f.write(json.dumps(log_line, ensure_ascii=False) + "\n")
return str(out_fp), f"RTF: {log_line['rtf']} | Thời gian synth: {log_line['synth_sec']} s"
with gr.Blocks(title="XTTS v2 VI Demo") as app:
gr.Markdown("Bản demo XTTS v2 tiếng Việt. Chọn preset, nạp tham chiếu, nhập văn bản, synth và nghe ngay.")
with gr.Row():
text = gr.Textbox(label="Văn bản", value="Chào bạn, đây là bản demo tiếng Việt chạy local.")
with gr.Row():
preset = gr.Dropdown(choices=list(PRESETS.keys()), value="Trò chuyện", label="Preset")
temp = gr.Slider(minimum=0.4, maximum=0.9, value=0.60, step=0.01, label="Temperature")
lang = gr.Dropdown(choices=["vi"], value="vi", label="Ngôn ngữ")
with gr.Row():
prof_list = gr.Dropdown(choices=[x[1] for x in list_profiles()], label="Chọn profile (.json)", value=None)
ref_path = gr.Textbox(label="Đường dẫn file tham chiếu", placeholder="profiles/ref_01.wav")
ref_file = gr.File(label="Upload tham chiếu mới", file_types=[".wav", ".flac"], interactive=True)
with gr.Row():
prof_id = gr.Textbox(label="ID profile để lưu", value="ref_01")
save_btn = gr.Button("Lưu profile")
save_msg = gr.Markdown("")
with gr.Row():
run_btn = gr.Button("Synth và phát")
audio = gr.Audio(label="Kết quả", autoplay=True, interactive=False, type="filepath")
meta = gr.Markdown("")
def on_preset_change(p):
info = PRESETS[p]
return gr.update(value=info["temperature"]), f"Gợi ý: {info['hint']}"
def on_profile_pick(pjson):
rp, t, l = load_profile(pjson)
return rp, gr.update(value=t), gr.update(value=l)
def on_upload(file):
if file is None:
return ""
return str(file.name)
preset.change(on_preset_change, inputs=preset, outputs=[temp, meta])
prof_list.change(on_profile_pick, inputs=prof_list, outputs=[ref_path, temp, lang])
ref_file.change(on_upload, inputs=ref_file, outputs=ref_path)
save_btn.click(save_profile, inputs=[prof_id, ref_path, temp, lang], outputs=[save_msg, prof_list])
run_btn.click(synth, inputs=[text, preset, ref_path, temp, lang], outputs=[audio, meta])
if __name__ == "__main__":
app.queue().launch(server_name="127.0.0.1", server_port=7860, inbrowser=True)
- Cài và chạy UI
- Windows PowerShell:
cd demo_tts
python -m pip install gradio
python .\app_ui.py
- Linux bash:
cd demo_tts
python3 -m pip install gradio
python3 ./app_ui.py
- Trình duyệt sẽ mở http://127.0.0.1:7860. Chọn preset, nạp profiles/ref_01.wav, nhập văn bản, bấm Synth và nghe ngay trong thẻ Audio. Log thời gian được ghi vào logs/tts.log.
- Xác nhận đầu ra nghe được ngay trên loa
- CLI: dùng ffplay như ở bước 2 và 3. Bạn sẽ nghe trực tiếp qua loa mặc định.
- Web UI: thẻ Audio phát tự động, âm thanh ra loa mặc định của OS. Nếu không nghe thấy, kiểm tra mute của tab trình duyệt hoặc thiết bị phát mặc định.
- Mẹo cấu hình mặc định và kiểm tra nhanh
- Giữ temperature mặc định 0.60 trong profiles/ref_01.json để cân bằng tự nhiên và ổn định thanh điệu. Tin tức hạ về 0.55, kể chuyện nâng lên 0.65.
- Mỗi lần đổi file tham chiếu, chạy nhanh lệnh test một dòng ở bước 2 để bắt lỗi path, lỗi driver, hoặc lỗi định dạng trước khi vào UI.
- Khi cần benchmark lại độ trễ, xóa outputs/_warmup.wav để đo thời gian cold start, còn log RTF đã có trong logs/tts.log cho từng preset.
Bạn có thể dừng ở mức CLI nếu chỉ cần chứng minh pipeline, hoặc dùng web UI để demo cho nhóm không quen dòng lệnh. Cả hai đều dùng cùng profile và cùng preset để kết quả nhất quán.
Đánh giá chất lượng và đo lường
Mình chốt một bộ kiểm thử ngắn gọn nhưng đủ phủ để bạn phát hiện sớm lỗi thanh điệu và méo âm trong tiếng Việt, kèm quy trình đo F0 tự động và A/B mù, rồi ràng buộc pass-fail để quyết định có lặp lại tiền xử lý hay đổi chiến lược voice profile.
Bộ câu và cụm âm tiết kiểm thử
- Mục tiêu: phủ đủ 6 thanh, nhiều nguyên âm đơn đôi, phụ âm đầu phổ biến, và một số vần có phụ âm cuối.
- Cách dùng: đọc theo từng dòng, mỗi dòng là 6 âm tiết cùng cấu trúc khác thanh. Mỗi nhóm tạo một câu riêng khi synth để bạn dễ gắn nhãn.
Nhóm phụ âm đầu đơn, nguyên âm đơn
- ba, bá, bà, bả, bã, bạ
- be, bé, bè, bẻ, bẽ, bẹ
- bi, bí, bì, bỉ, bĩ, bị
- bo, bó, bò, bỏ, bõ, bọ
- bu, bú, bù, bủ, bũ, bụ
- bơ, bớ, bờ, bở, bỡ, bợ
- bư, bứ, bừ, bử, bữ, bự
Nhóm có phụ âm cuối để kiểm tra nuốt âm và ổn định F0 cuối âm tiết
- ban, bán, bàn, bản, bãn, bạn
- bang, báng, bàng, bảng, bãng, bạng
- bai, bái, bài, bải, bãi, bại
- bao, báo, bào, bảo, bão, bạo
Nhóm thay phụ âm đầu để thử tổ hợp phụ âm khác
- ta, tá, tà, tả, tã, tạ
- na, ná, nà, nả, nã, nạ
- ca, cá, cà, cả, cã, cạ
- pha, phá, phà, phả, phã, phạ
- tra, trá, trà, trả, trã, trạ
- nga, ngá, ngà, ngả, ngã, ngạ
3 câu ngắn để kiểm tra tự nhiên theo preset
- Tin tức: Bản tin trưa nay, nhiệt độ giảm nhẹ, mưa rào xuất hiện cục bộ.
- Kể chuyện: Chiều xuống, con phố im ắng, tiếng ve rỉ rả ngoài hiên.
- Trò chuyện: Chào bạn, mình test thử giọng này, bạn nghe ổn không?
Đo F0 theo âm tiết bằng crepe, kiểm tra hình dạng thanh
- Ý tưởng: đo F0 toàn clip, lấy F0 theo mỗi âm tiết từ nhãn thời gian, tính độ dốc đầu đuôi và so với mẫu hình thanh. Bạn có thể gắn nhãn ranh giới âm tiết nhanh bằng Praat hoặc Audacity rồi lưu CSV.
- Cài đặt:
python -m pip install crepe librosa numpy pandas soundfile scipy
- Tệp nhãn ví dụ boundaries.csv, mỗi dòng là âm tiết, thời điểm bắt đầu và kết thúc tính theo giây:
syllable,start,end
ba,0.10,0.40
bá,0.55,0.85
bà,1.00,1.30
bả,1.45,1.75
bã,1.90,2.20
bạ,2.35,2.65
- Script đo và chấm điểm sơ bộ theo độ dốc F0
# f0_eval_crepe.py
import numpy as np, pandas as pd, librosa, soundfile as sf, crepe
from scipy.signal import medfilt
def load_audio(path, sr=16000):
y, s = librosa.load(path, sr=sr, mono=True)
return y, s
def f0_crepe(y, sr, hop=0.005):
# crepe chuẩn hóa nội bộ về 16 kHz
time, frequency, confidence, _ = crepe.predict(y, sr, viterbi=True, step_size=int(hop*1000))
f0 = frequency.copy()
f0[confidence < 0.5] = 0.0
# lọc median nhẹ để giảm nhiễu
f0 = medfilt(f0, kernel_size=5)
return time, f0
def semitone(x, ref=1.0):
x = np.maximum(x, 1e-6)
return 12.0 * np.log2(x / ref)
def slope_score(f0_seg):
nz = f0_seg[f0_seg > 0]
if len(nz) < 3:
return 0.0, 0.0
st = semitone(nz, ref=np.median(nz))
# độ dốc đầu và cuối phần trăm trung vị theo bán cung
n = len(st)
head = np.mean(st[: max(1, n//5)]) - np.mean(st[max(1, n//5): 2*max(1, n//5)])
tail = np.mean(st[-max(1, n//5):]) - np.mean(st[-2*max(1, n//5): -max(1, n//5)])
return head, tail
def classify_tone(head, tail, var):
# Quy tắc thô:
# sắc lên, huyền xuống, hỏi xuống rồi lên, ngã lên rồi xuống, nặng thấp và ít biến thiên, ngang phẳng
up = head < 0 and tail > 0 # đi lên về cuối
down = head > 0 and tail < 0
if tail - head > 0.4 and var < 1.0: return "sắc"
if head - tail > 0.4 and var < 1.0: return "huyền"
if down and var > 0.6: return "hỏi"
if up and var > 0.6: return "ngã"
if var < 0.3 and head < 0.2 and tail < 0.2: return "ngang"
return "nặng" # phần còn lại coi là thấp hoặc gãy
def eval_file(wav_path, csv_bound):
y, sr = load_audio(wav_path, 16000)
t, f0 = f0_crepe(y, sr, hop=0.005)
df = pd.read_csv(csv_bound)
results = []
for _, r in df.iterrows():
mask = (t >= r.start) & (t <= r.end)
f0_seg = f0[mask]
h, te = slope_score(f0_seg)
var = np.std(semitone(f0_seg[f0_seg > 0], ref=np.median(f0_seg[f0_seg > 0]) )) if np.any(f0_seg>0) else 0
pred = classify_tone(h, te, var)
results.append({"syllable": r.syllable, "head": h, "tail": te, "var": var, "pred": pred})
out = pd.DataFrame(results)
print(out)
return out
if __name__ == "__main__":
# ví dụ chạy: python f0_eval_crepe.py
eval_file("outputs/ba_sac_huyen.wav", "boundaries.csv")
- RMVPE: nếu bạn đang dùng pipeline RVC, RMVPE thường đã được tích hợp để trích F0 mượt và ít lỗi octave. Bạn có thể xuất đường F0 bằng chính công cụ đó rồi áp dụng cùng logic tính độ dốc và phân loại như trên.
Quy trình A/B mù với 3 người nghe tiếng Việt
- Chuẩn bị hai phiên bản cho mỗi câu: A là pipeline hiện tại, B là phiên bản thay đổi bạn muốn kiểm chứng, ví dụ tham chiếu đã dereverb hoặc đã làm mượt F0 trước khi trích embedding.
- Tập đánh giá: 20 mục gồm
- 10 dòng âm tiết theo các nhóm trên, chọn ngẫu nhiên 10 dòng.
- 10 câu ngắn nội dung tự nhiên, gồm 3 câu preset ở trên và 7 câu bạn dùng trong app.
- Cách chấm:
- Nghe bằng tai nghe kín, âm lượng vừa phải, môi trường yên tĩnh.
- Mỗi mục phát hai file theo trật tự ngẫu nhiên AB hoặc BA, người chấm không biết thứ tự.
- Ghi 2 chỉ số:
- Nhận đúng thanh: chọn 1 trong 6 thanh cho mỗi âm tiết mục tiêu đã gạch chân trong phiếu, đếm tỷ lệ đúng trên toàn bộ âm tiết. Với câu tự nhiên, khoanh vùng 1 âm tiết mục tiêu mỗi câu.
- Độ tự nhiên MOS 1 đến 5 cho từng phiên bản, với 1 là rất kém, 5 là rất tự nhiên.
- Tổng hợp:
- Tone accuracy per system = số âm tiết nhận đúng / tổng âm tiết.
- MOS per system = trung bình 3 người x 20 mục.
- So sánh chênh lệch theo cặp A vs B, đếm số lần B thắng về tone accuracy và MOS.
Log độ trễ từng đoạn và RTF
- Đo theo từng chunk văn bản để có phân phối p50, p95 và RTF:
# latency_log.py
import re, time, json, wave, contextlib
from TTS.api import TTS
import torch
def sent_tokenize_vi(text):
parts = re.split(r'([\.!\?])', text)
sents = []
for i in range(0, len(parts)-1, 2):
s = (parts[i] + parts[i+1]).strip()
if s: sents.append(s)
if len(parts) % 2 == 1 and parts[-1].strip():
sents.append(parts[-1].strip())
return sents
def wav_dur(path):
with contextlib.closing(wave.open(path,'rb')) as wf:
return wf.getnframes()/float(wf.getframerate())
device = "cuda" if torch.cuda.is_available() else "cpu"
tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(device)
def synth_and_log(text, ref, lang="vi", log_fp="logs_latency.jsonl"):
sents = sent_tokenize_vi(text)
all_stats = []
for i, s in enumerate(sents):
out = f"outputs/chunk_{i:02d}.wav"
t0 = time.perf_counter()
tts.tts_to_file(text=s, speaker_wav=ref, language=lang, file_path=out, temperature=0.6)
t1 = time.perf_counter()
dur = wav_dur(out)
synth = t1 - t0
rtf = synth / max(dur, 1e-6)
rec = {"i": i, "text": s, "audio_sec": round(dur,3), "synth_sec": round(synth,3), "rtf": round(rtf,3)}
all_stats.append(rec)
with open(log_fp, "a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
return all_stats
if __name__ == "__main__":
text = "Bản tin trưa nay, nhiệt độ giảm nhẹ. Chiều xuống, con phố im ắng."
synth_and_log(text, "profiles/ref_01.wav")
- Đọc file logs_latency.jsonl để tính p50, p95 RTF và phát hiện outlier chunk.
Tiêu chí quyết định lặp lại tiền xử lý hoặc đổi chiến lược voice profile
- Pass cho bản demo:
- Tone accuracy trung bình của 3 người nghe ≥ 90 phần trăm trên 20 mục.
- MOS tự nhiên trung bình ≥ 3.8 trên thang 5 điểm.
- Không quá 1 chunk có RTF > 0.9 trong một lượt synth, và p95 RTF ≤ 0.7.
- Nếu trượt bất kỳ mục nào, hành động theo thứ tự:
- Sai thanh nhiều trên sắc, hỏi, ngã hoặc đường F0 phẳng ở cuối âm tiết
- Áp dụng dereverb nhẹ cho file tham chiếu, rút tham chiếu về 15 đến 45 giây ít rung, rồi trích embedding lại.
- Nếu còn sai, làm mượt F0 của file tham chiếu trước bước trích embedding như đã hướng dẫn, trích lại embedding và A/B.
- Vẫn sai thanh sau khi xử lý
- Đổi chiến lược voice profile: dùng vài chục giây pseudo-speech trung tính tạo bằng RVC từ cùng giọng, hoặc thay bằng tham chiếu là tiếng nói tự nhiên của người đó nếu có.
- MOS tự nhiên thấp nhưng tone đúng
- Hạ temperature về 0.5 đến 0.6, tinh chỉnh chấm câu và length_scale theo preset. Nếu vẫn cứng hoặc run, thay tham chiếu sạch hơn, ít vang hơn.
- RTF cao
- Chia câu nhỏ hơn, đảm bảo FP16 và GPU cho cả TTS và vocoder, bật warm-up rồi đo lại. Nếu p95 vẫn > 0.7, giảm temperature và kiểm tra driver CUDA.
Rủi ro pháp lý và đạo đức
Bạn chỉ được dùng giọng hát hoặc giọng đã tách nếu có quyền rõ ràng từ người sở hữu quyền đối với giọng đó. Quyền ở đây gồm quyền đối với bản ghi, quyền đối với hình tượng giọng nói và quyền riêng tư. File a cappella tải từ mạng hay tách ra từ bài hát thương mại không mặc nhiên cho phép clone giọng.
Quy trình xin phép và lưu bằng chứng, gợi ý áp dụng ngay
- Xác định chủ thể cần đồng ý
- Người có giọng và, nếu bản ghi do hãng hoặc bên thứ ba nắm giữ, cả đơn vị sở hữu bản ghi.
- Xin phép bằng văn bản, nêu rõ
- Phạm vi: mục đích sử dụng, kênh phát, thương mại hay nội bộ, có được chia sẻ công khai hay không.
- Thời hạn, khu vực, quyền hủy bỏ, yêu cầu gỡ bỏ, bồi dưỡng nếu có.
- Dữ liệu cung cấp: file tham chiếu, cho phép xử lý kỹ thuật để tạo voice profile.
- Lưu bằng chứng gọn gàng, có khả năng kiểm tra
- Gói một thư mục duy nhất cho mỗi giọng: consent.pdf hoặc email.eml, bản nháp hợp đồng, thông tin liên hệ.
- Lưu hash của file tham chiếu để ràng buộc dữ liệu đã được phép:
# Tạo SHA-256 và lưu kèm ngày giờ
sha256sum ref_vocal.wav > consent/REF_VOCAL_SHA256.txt
- Ghi nhật ký thay đổi: ai truy cập, sửa gì, khi nào. Đặt quyền chỉ đọc cho thư mục consent.
Mẫu đồng ý điền sẵn
Consent ID: C-2026-07-001
Name: Nguyễn Văn A
Email: nguyenvana@example.com
Scope: Cho phép tạo và dùng voice profile từ file “ref_vocal.wav” để demo TTS nội bộ, phi thương mại.
Distribution: Không đăng công khai, không chạy quảng cáo, không dùng cho nội dung chính trị.
Term: 12 tháng, hết hạn 2027-07-31. Có thể rút lại bất cứ lúc nào, bên nhận gỡ trong 7 ngày.
Revocation Contact: nguyenvana@example.com
Prohibited: Mạo danh, xác thực sinh trắc, tổng đài lừa đảo.
Signature: Nguyễn Văn A, 2026-07-18
Gắn nhãn tiếng nói tổng hợp trong UI và file xuất ra
- UI: hiển thị badge cố định “Tiếng nói tổng hợp” cạnh nút Play và dưới tên preset. Mặc định bật chèn câu mở đầu ngắn 0.6 giây: “Lưu ý, đây là tiếng nói tổng hợp” khi phát và khi export, cho phép tắt chỉ khi hồ sơ voice có consent hợp lệ.
- Tên file: thêm tiền tố rõ ràng.
AI_TTS_vi_tintuc_C-2026-07-001_20260718.wav
- Metadata audio bằng FFmpeg
# WAV: ghi comment RIFF INFO
ffmpeg -y -i out.wav -c copy \
-metadata comment="Synthetic speech - Voice ID C-2026-07-001 - Do not redistribute" \
AI_TTS_vi_tintuc_C-2026-07-001.wav
# MP3: ghi ID3 comment và TXXX
ffmpeg -y -i out.mp3 -c copy \
-metadata comment="Synthetic speech" \
-metadata TXXX="VoiceConsentID=C-2026-07-001" \
AI_TTS_vi_trochuyen_C-2026-07-001.mp3
# FLAC: Vorbis comment
ffmpeg -y -i out.flac -c copy \
-metadata LABEL="Synthetic speech" \
-metadata VOICE_CONSENT_ID="C-2026-07-001" \
AI_TTS_vi_kechuyen_C-2026-07-001.flac
- Sidecar JSON để máy đọc được
{
"type": "synthetic_speech",
"language": "vi",
"voice_consent_id": "C-2026-07-001",
"usage_restrictions": ["internal_demo_only", "no_public_distribution"]
}
Hạn chế chia sẻ công khai nếu không có phép
- Nếu phạm vi chỉ cho nội bộ, tắt mọi nút Share, không auto upload, mặc định lưu cục bộ. Khi cần gửi nội bộ, chỉ dùng mạng công ty, kèm sidecar JSON ở trên.
- Không đăng lên mạng xã hội, không gửi cho khách hàng, không dùng làm giọng IVR trừ khi điều khoản cho phép.
Xử lý dữ liệu nhạy cảm hoàn toàn local để giảm rủi ro
- Tách nhạc, tiền xử lý, trích embedding, suy luận TTS và lưu trữ đều chạy trên PC của bạn, không gọi API bên ngoài. Tắt crash reporting gửi log ra Internet.
- Mã hóa thư mục chứa consent và dữ liệu bằng BitLocker hoặc LUKS. Tự động xóa file đầu vào sau khi tạo embedding nếu không cần lưu, chỉ giữ hash.
- Nhật ký chỉ ghi số liệu kỹ thuật, không lưu văn bản nhạy cảm của người dùng trừ khi bắt buộc và đã nêu trong consent.
Ngăn chặn mạo danh và lạm dụng ngay trong app
- Chỉ mở khóa tính năng “custom voice” khi có consent ID hợp lệ trong kho lưu trữ.
- Cảnh báo trên UI mỗi khi xuất file: “Không dùng để mạo danh hoặc xác thực giọng nói”.
- Với giọng người chưa đủ tuổi, người đã qua đời hoặc người nổi tiếng, chỉ thực hiện khi có giấy tờ ủy quyền hợp lệ từ người giám hộ hoặc đại diện được ủy quyền.
Lỗi thường gặp và cách cứu nhanh
Khi đầu ra nghe sai hoặc máy chạy ì ạch, đừng mò mẫm vô hướng. Dưới đây là phác đồ xử lý nhanh theo triệu chứng thường gặp, đi theo thứ tự ưu tiên để bạn khôi phục chất lượng và độ trễ trong vài phút.
Quy trình ưu tiên chung
- Kiểm văn bản: ép Unicode về NFC.
- Rút ngắn tham chiếu: chọn 10 đến 20 giây giọng sạch, hoặc 15 đến 45 giây với hát ít rung.
- Làm sạch tham chiếu: dereverb nhẹ, mượt F0 chỉ cho bước trích embedding.
- Tách câu: giữ mỗi câu khoảng 15 đến 25 âm tiết, chèn ngắt 150 đến 250 ms giữa các mệnh đề.
- Ổn định suy luận: hạ length_scale nhẹ, giảm temperature một nấc.
- Hiệu năng: bật FP16, cache embedding, thu nhỏ chunk suy luận để tránh OOM và giảm RTF.
Triệu chứng 1: Sai thanh điệu, giọng run hoặc kéo luyến như hát
- Nguyên nhân thường gặp
- Mẫu tham chiếu là a cappella có vibrato sâu hoặc reverb dài khiến embedding mang theo style hát.
- Temperature quá cao làm đường F0 dao động hơn mức cần.
- Cách cứu nhanh
- Rút ngắn và thay mẫu: lấy 10 đến 20 giây nói sạch nếu có. Nếu chỉ có hát, chọn đoạn ít vibrato, phụ âm rõ, tổng dài 15 đến 45 giây.
- Dereverb nhẹ và mượt F0 rồi mới trích embedding. Chỉ áp dụng lên file tham chiếu, không đụng vào đầu vào văn bản cần synth.
- Giảm temperature một nấc về khoảng 0.5 đến 0.6. Hạ length_scale nhẹ về 0.95 đến 1.02 để nhịp tự nhiên mà không kéo giọng.
- Mẹo kiểm nhanh
- Nếu vẫn còn run hoặc sai dấu trên câu có đủ 6 thanh, giảm thêm temperature hoặc đổi sang đoạn tham chiếu khác ít rung hơn.
Triệu chứng 2: Lặp âm, nuốt âm ở câu dài
- Nguyên nhân thường gặp
- Câu quá dài, ít dấu ngắt khiến mô hình autoregressive mất ổn định.
- Văn bản ở dạng NFD làm tokenizer lệch, tăng độ dài và rủi ro lặp.
- Cách cứu nhanh
- Tách câu theo dấu phẩy, chấm phẩy, chấm. Nhắm mỗi mệnh đề 15 đến 20 âm tiết, tối đa khoảng 25. Ghép hậu kỳ bằng khoảng lặng 200 ms.
- Ép NFC 100% trước khi gọi TTS.
- Giảm temperature về 0.55 đến 0.65, length_scale quanh 0.98 đến 1.05 để dễ bám attention.
- Mẫu code chuẩn hóa NFC và tách văn bản, kèm synth từng mệnh đề
- Ý nghĩa: ép NFC, chia đoạn ngắn, synth tuần tự để tránh lặp.
# Python 3.10
import unicodedata, re, time
from TTS.api import TTS
def to_nfc(s: str) -> str:
return unicodedata.normalize("NFC", s)
def split_vi(text: str, target_syllables=18, hard_max=25):
sents = re.split(r'([.!?])', text)
merged = []
for i in range(0, len(sents)-1, 2):
merged.append((sents[i] + sents[i+1]).strip())
if len(sents) % 2 == 1:
merged.append(sents[-1].strip())
chunks, buf, count = [], [], 0
for sent in merged:
syls = len(sent.split())
if count + syls <= target_syllables or not buf:
buf.append(sent); count += syls
else:
chunks.append(" ".join(buf))
buf, count = [sent], syls
if count >= hard_max:
chunks.append(" ".join(buf))
buf, count = [], 0
if buf: chunks.append(" ".join(buf))
return [c for c in chunks if c]
text = to_nfc("Hôm nay trời đẹp, chúng ta thử hệ thống đọc tiếng Việt với đủ sáu thanh nhé?")
parts = split_vi(text)
tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to("cuda")
for i, p in enumerate(parts):
tts.tts_to_file(text=p, speaker_wav="ref_clean.wav", language="vi", file_path=f"out_{i}.wav")
time.sleep(0.2) # chèn khoảng lặng 200 ms khi ghép file
Triệu chứng 3: Đọc sai ký tự dấu hoặc crash lạ do văn bản
- Nguyên nhân thường gặp
- Văn bản ở NFD, ký tự base + dấu kết hợp làm tokenizer lệch thống kê.
- Cách cứu nhanh
- Luôn ép NFC trước khi gọi TTS, cả ở frontend và backend. Chặn nhập liệu không phải NFC ở UI, tự động sửa về NFC.
- Mẫu kiểm và ép NFC inline
import unicodedata
def ensure_nfc(s: str) -> str:
if any(unicodedata.combining(c) for c in s):
return unicodedata.normalize("NFC", s)
return s
Triệu chứng 4: Tham chiếu quá dài khiến drift style
- Nguyên nhân thường gặp
- Dùng vài phút hát thuần, embedding bị lôi theo nhịp hát, kéo dài nguyên âm, nhấn sai.
- Cách cứu nhanh
- Cắt còn 15 đến 45 giây phần ít rung nhất. Ưu tiên các câu ngắn, phụ âm rõ.
- Nếu có vài mẩu 3 đến 8 giây sạch, gộp bằng cách nối chuỗi thành một file duy nhất trước khi trích embedding.
- Nếu vẫn không ổn định, tạo một đoạn pseudo-speech trung tính từ chính giọng đó bằng VC rồi dùng làm tham chiếu.
Triệu chứng 5: GPU OOM hoặc RTF cao
- Nguyên nhân thường gặp
- Precision float32, câu quá dài, không cache embedding, vocoder chạy CPU.
- Cách cứu nhanh
- Bật FP16, no-grad, warmup. Giữ batch size 1. Đảm bảo vocoder và TTS đều chạy trên GPU.
- Thu nhỏ chunk suy luận: nhắm mỗi đoạn 4 đến 6 giây audio tương đương.
- Cache speaker embedding để không mã hóa lại mỗi lượt.
- Kiểm tra driver CUDA, tắt ứng dụng chiếm VRAM.
- Mẫu bao FP16 và cache embedding khi synth nhiều câu
- Ý nghĩa: giảm VRAM, tăng tốc, tránh encode lại tham chiếu.
import torch
from TTS.api import TTS
tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to("cuda")
# Trích và cache embedding một lần
spk_emb = tts.get_speaker_embedding("ref_clean.wav") # dùng nếu bản TTS bạn cài có hàm này
texts = ["Xin chào bạn.", "Đây là bản demo tiếng Việt."]
torch.backends.cudnn.benchmark = True
with torch.inference_mode(), torch.cuda.amp.autocast(dtype=torch.float16):
for i, tx in enumerate(texts):
# Nếu API không nhận embedding trực tiếp, truyền lại speaker_wav và vẫn hưởng autocast
tts.tts_to_file(text=tx, language="vi", speaker_wav="ref_clean.wav", file_path=f"seg_{i}.wav")
- Nếu bản bạn dùng không có getspeakerembedding, vẫn giữ autocast và no-grad, và rút ngắn câu như phần tách văn bản ở trên.
Bảng triệu chứng nhanh
- Sai thanh điệu, run: rút ngắn tham chiếu, dereverb nhẹ, mượt F0 cho embedding, giảm temperature, tinh chỉnh length_scale nhẹ.
- Lặp hoặc nuốt âm: tách câu 15 đến 25 âm tiết, ép NFC, giảm temperature, giữ length_scale sát 1.
- Văn bản lỗi: luôn normalize NFC.
- Drift style: cắt tham chiếu ngắn hơn, chọn phần ít rung, hoặc thay bằng pseudo-speech trung tính của cùng giọng.
- OOM, RTF cao: FP16 + no-grad + warmup, chunk 4 đến 6 giây, cache embedding, đảm bảo chạy GPU.
Chốt lại, đừng sửa nhiều thứ cùng lúc. Áp dụng đúng thứ tự ưu tiên trên, nghe lại bằng bộ câu kiểm tra đủ 6 thanh, rồi mới chỉnh tiếp tham số tinh để giữ nhịp tự nhiên mà không đánh đổi tính đúng thanh điệu.
Phương án mở rộng và đóng gói
Mục tiêu là làm app bền vững theo thời gian: thay model linh hoạt, có thư viện kiểu đọc có thể mở rộng, đóng gói cài đặt dễ dùng, tận dụng cache embedding để giảm độ trễ, và có đo lường tự động để tối ưu theo miền nội dung.
Các khối triển khai
- Thư viện preset kiểu đọc và template dấu câu
- Tổ chức preset dưới dạng YAML để một preset vừa mang tham số suy luận vừa mang quy tắc chèn dấu ngắt và chia câu. Cấu trúc gợi ý: presets/<tên>.yaml, nạp ở runtime.
- Ví dụ hoàn chỉnh
# presets/news_vi.yaml
name: "Tin tức - VI"
engine: "xtts_v2"
language: "vi"
tts_params:
length_scale: 0.94
temperature: 0.55
text_rules:
normalize_unicode: "NFC"
punct_templates:
- rule: "insert_comma_before_contrast"
before_words: ["nhưng", "tuy nhiên", "dù vậy"]
pause_ms: 180
- rule: "ensure_final_period"
pause_ms: 0
chunker:
target_syllables: 18
hard_max: 25
join_silence_ms: 200
- Mẫu biến thể có thể thay đổi theo miền
name: "<Tên preset>"
engine: "<xtts_v2|piper_rvc>"
language: "vi"
tts_params:
length_scale: <float>
temperature: <float>
text_rules:
normalize_unicode: "NFC"
punct_templates: [{...}]
chunker:
target_syllables: <int>
hard_max: <int>
join_silence_ms: <int>
- Hàm áp dụng template dấu câu cơ bản
import re, unicodedata
def apply_punct_templates(text: str, tmpl: dict) -> str:
s = unicodedata.normalize("NFC", text) if tmpl.get("normalize_unicode")=="NFC" else text
# Chèn dấu phẩy trước từ nối tương phản
for w in tmpl.get("punct_templates", []):
if w.get("rule") == "insert_comma_before_contrast":
for token in w.get("before_words", []):
s = re.sub(rf"\s+({token})\b", r", \1", s, flags=re.IGNORECASE)
if w.get("rule") == "ensure_final_period" and not re.search(r"[.!?…]$", s.strip()):
s = s.rstrip() + "."
return s
- Mô-đun hóa để hoán đổi model và fallback Piper + RVC
- Định nghĩa giao diện trừu tượng, rồi viết adapter cho XTTS v2 và một adapter fallback Piper+RVC. Cấu hình chọn engine qua file JSON để đổi mà không sửa code.
{
"engine": "xtts_v2",
"xtts_v2": { "checkpoint": "tts_models/multilingual/multi-dataset/xtts_v2", "device": "cuda" },
"piper_rvc": {
"piper_model": "models/vi/model.onnx",
"piper_config": "models/vi/model.onnx.json",
"rvc_model": "models/rvc/target.pth",
"rvc_index": "models/rvc/target.index"
}
}
from abc import ABC, abstractmethod
class ITTS(ABC):
@abstractmethod
def synth_to_wav(self, text: str, preset: dict, ref_path: str, out_path: str) -> None: ...
class XTTSAdapter(ITTS):
def __init__(self, checkpoint: str, device="cuda"):
from TTS.api import TTS
self.tts = TTS(checkpoint).to(device)
def synth_to_wav(self, text, preset, ref_path, out_path):
self.tts.tts_to_file(text=text, language="vi",
speaker_wav=ref_path,
file_path=out_path,
length_scale=preset["tts_params"]["length_scale"],
temperature=preset["tts_params"]["temperature"])
class PiperRVCAdapter(ITTS):
def __init__(self, piper_model, piper_config, rvc_model, rvc_index):
# Khởi tạo wrapper Piper và RVC theo tool bạn dùng
self.piper_model = piper_model
self.piper_config = piper_config
self.rvc_model = rvc_model
self.rvc_index = rvc_index
def synth_to_wav(self, text, preset, ref_path, out_path):
# 1) Dùng Piper sinh giọng đọc VI trung tính ra tmp.wav
# 2) Dùng RVC chuyển timbre sang giọng ref_path, xuất out_path
# Ghi chú: hiện thực tùy toolchain RVC bạn có trên máy
raise NotImplementedError("Hook Piper and RVC CLI here")
- Kinh nghiệm: luôn giữ preset chung cho cả hai adapter. Khi đổi engine, preset textrules và chunker vẫn tái dùng, chỉ ttsparams khác nhau.
- Đóng gói cài đặt dạng script hoặc installer
- Script cài đặt Windows 11 một chạm bằng PowerShell
# install.ps1 - tạo venv và cài phụ thuộc
$ErrorActionPreference = "Stop"
python -V
python -m venv .venv
.\.venv\Scripts\pip install --upgrade pip
# PyTorch GPU cho CUDA 12.1 - đổi theo driver nhà bạn
.\.venv\Scripts\pip install torch --index-url https://download.pytorch.org/whl/cu121
.\.venv\Scripts\pip install TTS gradio fastapi uvicorn soundfile librosa nara-wpe pyroomacoustics pydub numpy scipy
Write-Host "Done. Activate with .\.venv\Scripts\Activate.ps1"
- Đóng gói chạy một thư mục bằng PyInstaller để QA cài nhanh
# CMD hoặc PowerShell
.\.venv\Scripts\pyinstaller --noconfirm --name tts-vi-demo ^
--add-data "presets;presets" --collect-submodules TTS app.py
- Tạo installer Windows: dùng Inno Setup trỏ đến thư mục dist\tts-vi-demo và thêm bước tải model lần đầu khi chạy app.
- Lưu và tái sử dụng cache embedding
- Khóa cache nên là SHA-256 của file tham chiếu sau tiền xử lý cộng version pipeline. Tùy phiên bản XTTS v2, bạn có thể:
- Cache theo phiên: map hash đến embedding trong RAM nếu runtime cho phép truy xuất embedding.
- Hoặc cache file tham chiếu đã làm sạch và cắt ngắn, tái dùng path này cho tất cả lượt synth để giảm I/O và xử lý lặp.
import hashlib, pathlib, json, numpy as np
from datetime import datetime
def file_hash(p: str) -> str:
import hashlib
h = hashlib.sha256()
with open(p, "rb") as f:
for chunk in iter(lambda: f.read(1<<20), b""): h.update(chunk)
return h.hexdigest()
def try_cache_embedding(tts, ref_path: str, cache_dir="cache"):
h = file_hash(ref_path)
cache_dir = pathlib.Path(cache_dir); cache_dir.mkdir(exist_ok=True)
npy = cache_dir / f"{h}.npy"
meta = cache_dir / f"{h}.json"
if npy.exists():
emb = np.load(npy)
return emb
# Chỉ khi bản TTS có API trích embedding
if hasattr(tts, "get_speaker_embedding"):
emb = tts.get_speaker_embedding(ref_path)
np.save(npy, np.array(emb))
meta.write_text(json.dumps({"ref": ref_path, "hash": h, "ts": datetime.utcnow().isoformat()}))
return emb
return None # fallback: dùng speaker_wav như bình thường
- Kế hoạch đo lường tự động và tối ưu theo miền nội dung
- Tự động log độ trễ và RTF theo câu, kèm preset, độ dài âm tiết, và kích hoạt cảnh báo khi RTF vượt ngưỡng hoặc câu quá dài.
import time, json, os
def synth_with_metrics(eng: ITTS, text: str, preset: dict, ref: str, out: str, log_path="logs/metrics.jsonl"):
os.makedirs(os.path.dirname(log_path), exist_ok=True)
t0 = time.time()
eng.synth_to_wav(text, preset, ref, out)
t1 = time.time()
# Ước lượng độ dài âm thanh dựa vào số âm tiết và length_scale nếu bạn chưa decode WAV tại đây
syls = len(text.split())
dur_guess = max(1.5, syls * 0.18 * preset["tts_params"]["length_scale"])
rtf = (t1 - t0) / dur_guess
rec = {
"ts": int(t0),
"preset": preset["name"],
"engine": preset["engine"],
"syllables": syls,
"length_scale": preset["tts_params"]["length_scale"],
"temperature": preset["tts_params"]["temperature"],
"e2e_sec": round(t1 - t0, 3),
"rtf_est": round(rtf, 3)
}
with open(log_path, "a", encoding="utf-8") as f: f.write(json.dumps(rec, ensure_ascii=False) + "\n")
- Quy tắc tối ưu theo miền nội dung
- Tin tức: ép NFC, chèn dấu phẩy trước từ nối tương phản, length_scale 0.92 đến 0.98. Cảnh báo nếu câu vượt 25 âm tiết thì tách.
- Kể chuyện: cho phép xuống dòng nhiều hơn, length_scale 1.05 đến 1.12, thêm dấu ba chấm nhẹ ở chuyển ý.
- Trò chuyện: câu ngắn, giữ length_scale sát 1.0, loại cảm thán thừa.
- Lộ trình hoàn thiện: thu log 200 đến 300 câu mỗi miền, chọn preset có RTF ổn định và ít báo cáo nghe nhầm thanh, rồi khóa preset thành bản v1 cho môi trường đó.
Kết luận
Bạn và mình đã dựng được demo TTS chạy local lấy timbre từ a cappella, tạo 3 kiểu đọc ổn định, giữ thanh điệu dễ nghe và đo được RTF cùng nhật ký kiểm thử để theo dõi chất lượng. Kiến trúc, preset và dự phòng Piper + RVC đều có thể tái lập và mở rộng khi cần.
Lưu ý quan trọng: chất lượng phụ thuộc mạnh vào tham chiếu sạch 10 đến 20 giây ít vang và ít rung, nên ưu tiên chọn đoạn mẫu và tiền xử lý trước mọi tối ưu khác.