Bỏ qua

Chuyển văn bản thành giọng nói và Tạo giọng nói

Tổng hợp chuyển văn bản thành giọng nói (TTS) đảo ngược pipeline của ASR, sinh ra âm thanh tự nhiên từ văn bản viết. File này bao quát pipeline TTS (chuẩn hóa văn bản, G2P, mô hình âm học, vocoder), Tacotron, WaveNet, HiFi-GAN, voice cloning, voice conversion, và voice activity detection (VAD).

  • Trong file 01, ta đã xây dựng bộ công cụ xử lý tín hiệu: dạng sóng, spectrogram, dải lọc mel, và MFCC. Trong file 02, ta đã biến tiếng nói thành văn bản. Giờ ta đảo mũi tên: cho trước văn bản, hãy tổng hợp tiếng nói tự nhiên. Đây là chuyển văn bản thành giọng nói (text-to-speech — TTS), một bài toán còn mở ra cánh cửa cho voice conversion, voice cloning, và voice activity detection.

  • Hãy hình dung TTS giống như một buổi biểu diễn sân khấu. Kịch bản chính là đầu vào văn bản. Một đạo diễn (mô hình âm học) quyết định mỗi câu thoại nên nghe như thế nào, cao độ, nhịp điệu, sự nhấn mạnh. Dàn nhạc (vocoder) sau đó biểu diễn bản nhạc, tạo ra những sóng âm thực tế mà khán giả nghe được. TTS nơ-ron hiện đại thay thế lối diễn đạt cứng nhắc, máy móc của các hệ thống dựa trên luật bằng những màn trình diễn sánh ngang người nói thực thụ.

Pipeline TTS: văn bản được chuẩn hóa, chuyển thành âm vị, qua mô hình âm học để tạo mel spectrogram, rồi qua vocoder để sinh dạng sóng cuối

  • Pipeline TTS — pipeline TTS chuẩn có bốn giai đoạn: (1) chuẩn hóa văn bản, (2) chuyển đổi âm vị, (3) mô hình âm học, và (4) vocoder. Một số hệ thống hiện đại gộp giai đoạn 3 và 4 thành một mô hình đầu-cuối duy nhất, nhưng sự phân rã khái niệm vẫn hữu ích.

  • Chuẩn hóa văn bản (text normalisation) chuyển văn bản thô thành dạng có thể phát âm được. Các từ viết tắt được mở rộng ("Dr." thành "Doctor"), số trở thành từ ("1984" thành "nineteen eighty-four"), ký hiệu tiền tệ được nói ra ("$5" thành "five dollars"), và các URL hay ký tự đặc biệt được xử lý. Giai đoạn này thường dựa trên luật với ngữ pháp đặc thù ngôn ngữ, dù cũng có các mô hình chuẩn hóa nơ-ron. Lỗi ở đây lan truyền xuống mọi giai đoạn hạ nguồn: nếu "St." được đọc là "saint" thay vì "street", cả câu nói sẽ sai.

  • Chuyển đổi chữ-viết-thành-âm-vị (Grapheme-to-phoneme — G2P) ánh xạ văn bản đã chuẩn hóa thành một dãy âm vị. Tiếng Anh nổi tiếng bất quy tắc ("though", "through", "tough" đều dùng "ough" khác nhau), nên tra cứu từ điển (CMU Pronouncing Dictionary) xử lý các từ phổ biến trong khi một mô hình sequence-to-sequence nơ-ron (encoder-decoder của chương 06 hay transformer của chương 07) xử lý các từ ngoài từ vựng. Các ngôn ngữ có chính tả nông (Spanish, Finnish) cần G2P đơn giản hơn. Đầu ra thường là một dãy IPA (Bảng phiên âm quốc tế) hoặc một tập âm vị nội bộ tương đương.

  • Mô hình âm học (acoustic models) tiêu thụ dãy âm vị và tạo ra một biểu diễn âm học trung gian, hầu như luôn là một mel spectrogram (file 01). Mel spectrogram nắm bắt bao phổ (spectral envelope) tại mỗi khung thời gian, mã hóa thông tin liên quan đến cảm quan mà vocoder cần để tái tạo dạng sóng. Mô hình âm học phải quyết định thời lượng (mỗi âm vị kéo dài bao lâu), cao độ (tần số cơ bản \(F_0\)), và năng lượng (độ to).

  • Vocoder nhận mel spectrogram và tạo ra dạng sóng âm thanh thô. Đây là một bài toán đảo ngược thiếu xác định (ill-posed): nhiều dạng sóng có thể tạo ra cùng một spectrogram vì thông tin pha đã bị loại bỏ. Các vocoder cổ điển (Griffin-Lim, WORLD) dùng các phương pháp lặp hoặc mô hình tín hiệu, nhưng vocoder nơ-ron giờ đây thống trị về chất lượng.

  • Vocoder: WaveNet (van den Oord và đồng sự, 2016) là vocoder nơ-ron đầu tiên tạo ra tiếng nói gần như không thể phân biệt với bản ghi âm người thật. Nó mô hình hóa dạng sóng tự hồi quy, dự đoán mỗi mẫu \(x_t\) được điều kiện hóa bởi tất cả các mẫu trước đó:

\[P(x) = \prod_{t=1}^{T} P(x_t \mid x_1, \ldots, x_{t-1}, c)\]
  • trong đó \(c\) là tín hiệu điều kiện (mel spectrogram). Mỗi mẫu là 16-bit, nên một softmax ngây thơ trên 65536 giá trị là không khả thi. WaveNet dùng mu-law companding để giảm xuống 256 mức lượng tử hóa, hoặc các biến thể sau này dùng một hỗn hợp phân bố logistic.

  • Khối xây dựng cốt lõi của WaveNet là tích chập nhân quả giãn (dilated causal convolution). Nhân quả (causal) nghĩa là trọng số bộ lọc chỉ nhìn vào các mẫu quá khứ (không rò rỉ tương lai). Giãn (dilated) nghĩa là bộ lọc bỏ qua các mẫu với khoảng cách tăng theo hàm số mũ: các hệ số giãn \(1, 2, 4, 8, \ldots, 512\). Điều này cho một trường tiếp nhận (receptive field) tăng theo hàm số mũ trong khi giữ số lượng tham số tuyến tính.

  • Hàm kích hoạt có cổng (gated activation) cho mỗi lớp là:

\[z = \tanh(W_{f} \ast x) \odot \sigma(W_{g} \ast x)\]
  • trong đó \(W_f\)\(W_g\) là trọng số tích chập của bộ lọc và cổng, \(\ast\) ký hiệu tích chập nhân quả giãn, và \(\odot\) là phép nhân từng phần tử. Cơ chế cổng này (từ LSTM của chương 06) cho phép mạng kiểm soát luồng thông tin.

  • WaveNet tạo ra chất lượng đặc biệt nhưng lại chậm kinh khủng khi suy luận: tạo ra một giây âm thanh 24 kHz đòi hỏi 24000 lượt forward tuần tự. Điều này thúc đẩy mọi nghiên cứu vocoder về sau.

  • WaveRNN (Kalchbrenner và đồng sự, 2018) thay thế chồng tích chập sâu của WaveNet bằng một mạng hồi quy một lớp. Nó chia mỗi mẫu 16-bit thành các thành phần thô (8 bit cao) và tinh (8 bit thấp), dự đoán mỗi cái bằng một GRU (chương 06). Cách tiếp cận dual softmax này giảm đáng kể tính toán trong khi duy trì chất lượng cao. WaveRNN đủ nhanh cho thời gian thực trên CPU di động với tối ưu hạt nhân cẩn thận.

  • WaveGlow (Prenger và đồng sự, 2019) là một vocoder dựa trên flow (flow-based) tránh hoàn toàn việc sinh tự hồi quy. Nó dùng một chuỗi các phép biến đổi khả nghịch (các lớp affine coupling, normalising flows của chương 06) để ánh xạ một phân bố Gaussian đơn giản sang phân bố dạng sóng. Huấn luyện cực đại hóa log-likelihood chính xác dùng công thức đổi biến:

\[\log P(x) = \log P(z) + \sum_{i} \log \left| \det \frac{\partial f_i}{\partial f_{i-1}} \right|\]
  • trong đó \(z = f(x)\) là biến tiềm ẩn thu được bằng cách đưa \(x\) qua flow. Khi suy luận, một mẫu \(z \sim \mathcal{N}(0, I)\) được rút ra và đẩy qua flow nghịch đảo trong một lượt song song duy nhất. WaveGlow đánh đổi kích thước mô hình (các mạng lớn cho các lớp coupling) lấy tốc độ sinh.

  • HiFi-GAN (Kong và đồng sự, 2020) dùng một mạng đối nghịch sinh (generative adversarial network — GAN) để tổng hợp dạng sóng từ mel spectrogram. Bộ sinh (generator) upsampling mel spectrogram qua một chuỗi các tích chập chuyển vị (transposed convolutions), mỗi lớp theo sau bởi một module hợp nhất trường tiếp nhận đa tỷ lệ (multi-receptive field fusion — MRF). Module MRF áp dụng nhiều khối dư (residual blocks) với các kích thước hạt nhân và tỷ lệ giãn khác nhau song song, sau đó cộng các đầu ra của chúng. Điều này cho phép bộ sinh nắm bắt các mẫu ở nhiều tỷ lệ thời gian cùng lúc.

Kiến trúc generator HiFi-GAN: đầu vào mel spectrogram đi qua các lớp upsampling tích chập chuyển vị, mỗi lớp theo sau bởi các khối multi-receptive field fusion kết hợp các chồng dư song song với các mẫu giãn khác nhau

  • HiFi-GAN dùng hai loại bộ phân biệt (discriminator). Bộ phân biệt đa chu kỳ (multi-period discriminator — MPD) định hình lại dạng sóng 1D thành 2D bằng cách gập nó ở các chu kỳ khác nhau (2, 3, 5, 7, 11), sau đó áp dụng tích chập 2D. Điều này nắm bắt các cấu trúc tuần hoàn ở các tần số cơ bản khác nhau. Bộ phân biệt đa tỷ lệ (multi-scale discriminator — MSD) hoạt động trên dạng sóng thô, bản giảm tốc 2x, và bản giảm tốc 4x, nắm bắt các mẫu ở các độ phân giải thời gian khác nhau.

  • Hàm mục tiêu huấn luyện kết hợp mất mát đối nghịch, mất mát tái tạo mel spectrogram (khoảng cách L1 giữa mel spectrogram của âm thanh sinh ra và ground truth), và mất mát khớp đặc trưng (feature matching loss — khoảng cách L1 giữa các đặc trưng trung gian của bộ phân biệt):

\[\mathcal{L}_G = \mathcal{L}_{\text{adv}}(G) + \lambda_{\text{mel}} \mathcal{L}_{\text{mel}}(G) + \lambda_{\text{fm}} \mathcal{L}_{\text{fm}}(G)\]
  • HiFi-GAN đạt chất lượng tổng hợp sánh ngang WaveNet trong khi nhanh hơn 1000 lần, cho phép sinh thời gian thực trên một GPU duy nhất.

  • Mô hình nguồn-lọc nơ-ron (Neural source-filter — NSF) kết hợp xử lý tín hiệu truyền thống với mạng nơ-ron. Trong mô hình nguồn-lọc cổ điển, tiếng nói có thanh (voiced) được tạo ra bởi một kích thích nguồn (xung chuỗi tuần hoàn ở tần số cơ bản \(F_0\)) qua một bộ lọc đường dẫn giọng nói (bao phổ). Các mô hình NSF thay thế bộ lọc thủ công bằng một mạng nơ-ron trong khi giữ tín hiệu nguồn rõ ràng. Đường bao \(F_0\) đầu vào cung cấp điều khiển cao độ tinh tế mà các vocoder thuần dữ liệu đôi khi gặp khó khăn.

  • Mô hình âm học: Tacotron (Wang và đồng sự, 2017) là hệ thống TTS đầu-cuối đầu tiên trực tiếp chuyển dãy ký tự thành mel spectrogram. Nó dùng kiến trúc encoder-decoder với cơ chế chú ý (chương 07). Encoder xử lý dãy ký tự/âm vị bằng một ngân hàng tích chập (convolution bank), mạng highway, và GRU hai chiều. Decoder là một GRU tự hồi quy dự đoán các khung mel từng cái một, dùng khung trước đó và ngữ cảnh chú ý làm đầu vào.

  • Tacotron 2 (Shen và đồng sự, 2018) tinh chỉnh kiến trúc đáng kể. Encoder là một chồng tích chập 1D 3 lớp theo sau bởi một LSTM hai chiều (chương 06). Decoder là LSTM 2 lớp với cơ chế chú ý nhạy vị trí (location-sensitive attention), làm cho cơ chế chú ý không chỉ phụ thuộc vào đầu ra encoder và trạng thái decoder mà còn vào các trọng số chú ý lũy kế từ các bước trước. Điều này ngăn lỗi thường gặp là attention bỏ qua hoặc lặp lại từ.

Kiến trúc Tacotron 2: encoder ký tự/âm vị với các lớp tích chập và BiLSTM, cơ chế chú ý nhạy vị trí căn chỉnh với các khung mel spectrogram, decoder tự hồi quy với dự đoán token dừng

  • Năng lượng chú ý nhạy vị trí cho vị trí encoder \(j\) tại bước decoder \(i\) là:
\[e_{i,j} = w^T \tanh(W_s s_{i-1} + W_h h_j + W_f f_{i,j} + b)\]
  • trong đó \(s_{i-1}\) là trạng thái decoder trước đó, \(h_j\) là đầu ra encoder tại vị trí \(j\), và \(f_{i,j}\) là đặc trưng vị trí thu được bằng cách tích chập các trọng số chú ý lũy kế \(\sum_{k<i} \alpha_{k,j}\) với một bộ lọc tích chập 1D. Các trọng số chú ý là \(\alpha_{i,j} = \text{softmax}(e_{i,j})\).

  • Decoder của Tacotron 2 cũng dự đoán xác suất token dừng (stop token) tại mỗi bước, chỉ ra khi nào mel spectrogram đã hoàn thành. Mel spectrogram đầu ra sau đó được chuyển đến một vocoder (ban đầu là WaveNet, sau này được thay bằng HiFi-GAN hoặc tương tự).

  • Bản chất tự hồi quy của Tacotron 2 khiến tốc độ tổng hợp bị giới hạn bởi số lượng khung mel. Với mel spectrogram điển hình 80 khung/giây, một câu nói 5 giây đòi hỏi 400 bước decoder tuần tự.

  • FastSpeech (Ren và đồng sự, 2019) giải quyết vấn đề tốc độ với một mô hình âm học không tự hồi quy (non-autoregressive). Thay vì sinh các khung mel tuần tự, FastSpeech sinh tất cả khung song song. Thách thức then chốt là xác định mỗi âm vị nên tạo ra bao nhiêu khung mel, mà FastSpeech xử lý bằng một bộ dự đoán thời lượng (duration predictor).

  • Bộ dự đoán thời lượng là một mạng tích chập nhỏ dự đoán thời lượng nguyên (số khung mel) cho mỗi âm vị. Trong quá trình huấn luyện, thời lượng thực được trích từ một mô hình thầy tự hồi quy đã được huấn luyện trước (Tacotron 2) bằng cách sử dụng sự căn chỉnh attention của nó. Khi suy luận, thời lượng dự đoán được dùng để mở rộng dãy ẩn mức âm vị lên mức khung thông qua một bộ điều chỉnh độ dài (length regulator) chỉ đơn giản là lặp lại biểu diễn ẩn của mỗi âm vị cho số khung đã dự đoán.

  • FastSpeech 2 (Ren và đồng sự, 2021) cải thiện FastSpeech bằng cách loại bỏ quá trình chưng cất thầy-trò. Nó trích thời lượng thực trực tiếp dùng forced alignment (từ các khung mô hình âm học trong file 02) và thêm các bộ điều chỉnh phương sai (variance adaptors) cho cao độ (\(F_0\)) và năng lượng ngoài thời lượng. Mỗi bộ điều chỉnh là một bộ dự đoán tích chập nhỏ có đầu ra tác động lên decoder:

\[ \begin{aligned} \hat{d}_i &= \text{DurationPredictor}(h_i) \\ \hat{p}_i &= \text{PitchPredictor}(h_i) \\ \hat{e}_i &= \text{EnergyPredictor}(h_i) \end{aligned} \]
  • trong đó \(h_i\) là trạng thái ẩn encoder cho âm vị \(i\). Khi huấn luyện, các giá trị thực được dùng; khi suy luận, các giá trị dự đoán cho phép kiểm soát rõ ràng về ngữ điệu (prosody). Khả năng kiểm soát này là một ưu điểm lớn của FastSpeech 2: điều chỉnh cao độ, tốc độ, hoặc năng lượng đơn giản như co giãn các đầu ra của bộ dự đoán.

  • FastSpeech 2 thường nhanh hơn Tacotron 2 từ 10–20 lần khi suy luận và tránh được các lỗi tự hồi quy phổ biến như bỏ qua từ, lặp lại, và suy sụp attention.

  • VITS (Kim và đồng sự, 2021) là một mô hình TTS đầu-cuối (end-to-end) tạo dạng sóng trực tiếp từ văn bản, loại bỏ giai đoạn vocoder riêng biệt. VITS kết hợp một autoencoder biến phân có điều kiện (VAE — chương 06) với normalising flows và huấn luyện đối nghịch. Posterior encoder ánh xạ mel spectrogram thực vào không gian tiềm ẩn, prior encoder ánh xạ âm vị (qua một bộ mã hóa văn bản dạng transformer và bộ dự đoán thời lượng) vào cùng không gian tiềm ẩn, và decoder (dựa trên HiFi-GAN) sinh dạng sóng từ các mẫu tiềm ẩn.

  • Hàm mục tiêu huấn luyện cho VITS kết hợp:

    • Mất mát tái tạo: VAE buộc phân bố tiềm ẩn mã hóa thông tin âm học
    • Phân kỳ KL: căn chỉnh prior có điều kiện văn bản với posterior có điều kiện âm thanh
    • Mất mát đối nghịch: các bộ phân biệt đảm bảo chất lượng dạng sóng
    • Mất mát thời lượng: huấn luyện bộ dự đoán thời lượng ngẫu nhiên
  • VITS tạo ra chất lượng cao hơn các hệ thống hai giai đoạn (FastSpeech 2 + HiFi-GAN) vì mô hình âm học và vocoder được tối ưu hóa đồng thời, tránh sự không khớp giữa mel spectrogram dự đoán và thực tế vốn làm giảm chất lượng hệ thống hai giai đoạn.

  • VALL-E (Wang và đồng sự, 2023) thay đổi căn bản cách tiếp cận TTS thành một bài toán mô hình hóa ngôn ngữ trên các token âm thanh rời rạc. Nó dùng một codec âm thanh nơ-ron (EnCodec) để biểu diễn tiếng nói như một dãy các mã rời rạc từ nhiều mức bảng mã. Với một đoạn văn bản và một câu nói đăng ký 3 giây (cũng được mã hóa thành token rời rạc), VALL-E dùng transformer mô hình ngôn ngữ để dự đoán các token âm thanh theo kiểu tự hồi quy.

  • VALL-E dùng hai mô hình: một mô hình tự hồi quy (AR) sinh mức bảng mã đầu tiên từng token một, và một mô hình không tự hồi quy (NAR) dự đoán các mức bảng mã còn lại song song, được điều kiện hóa bởi mức đầu tiên và lẫn nhau. Cách tiếp cận codec language model này cho phép khả năng sao chép giọng nói zero-shot đáng kinh ngạc: một mẫu 3 giây đủ để tái tạo giọng nói của một người nói, âm sắc, thậm chí cả tông cảm xúc.

  • StyleTTS (Li và đồng sự, 2022) và StyleTTS 2 tách tiếng nói thành các thành phần nội dung và phong cách. Một bộ mã hóa phong cách (style encoder) trích xuất vector phong cách từ âm thanh tham chiếu, nắm bắt danh tính người nói, ngữ điệu, và điều kiện ghi âm. Khi suy luận, phong cách có thể được lấy mẫu từ một phân bố prior đã học hoặc được chuyển từ một câu nói tham chiếu. StyleTTS 2 dùng các mô hình khuếch tán (diffusion models, chương 08) cho prior phong cách, tạo ra ngữ điệu đa dạng và tự nhiên.

  • Kokoro (2024) là một mô hình TTS mã nguồn mở nhẹ, chất lượng cao với kích thước nhỏ (~82M tham số) và độ tự nhiên ấn tượng. Nó dùng kiến trúc lấy cảm hứng từ StyleTTS 2 với prior phong cách dựa trên khuếch tán và một vocoder ISTFTNet được tinh chỉnh dự đoán trực tiếp các hệ số STFT (từ file 01) thay vì mẫu dạng sóng thô. Dù chỉ bằng một phần nhỏ kích thước của các mô hình như VALL-E, Kokoro đạt độ tự nhiên gần như con người cho tiếng Anh, Nhật, Pháp, Hàn, và Trung, chứng minh rằng dữ liệu huấn luyện được tuyển chọn cẩn thận và thiết kế kiến trúc hiệu quả có thể cạnh tranh với phép scale kiểu vũ lực. Kích thước nhỏ của Kokoro làm nó thực tế cho triển khai cục bộ và biên.

  • Orpheus (Canopy Labs, 2025) là một họ các mô hình TTS mã nguồn mở (1B và 3B tham số) xây dựng trên mô hình codec language model tiên phong bởi VALL-E. Orpheus đưa ý tưởng này đi xa hơn với nhân LLM (Llama 3 được tinh chỉnh) sinh trực tiếp các token codec âm thanh SNAC. Tính năng nổi bật của nó là khả năng biểu cảm cảm xúc giống con người: nó xử lý tiếng cười, thở dài, ngập ngừng, và ngữ điệu cảm xúc với độ tự nhiên đáng chú ý. Orpheus có thể được thúc đẩy bằng các thẻ như [laugh] hoặc [sigh] trong văn bản đầu vào, cho phép kiểm soát tinh tế về biểu cảm phi ngôn ngữ.

  • Dia (Nari Labs, 2025) là một mô hình TTS đối thoại mã nguồn mở tạo ra các cuộc hội thoại đa người nói thực tế từ một bản ghi văn bản duy nhất. Xây dựng trên transformer encoder-decoder 1.6B tham số, Dia xử lý việc chuyển lượt, giọng của từng người nói cụ thể, và các tín hiệu phi ngôn ngữ (tiếng cười, khoảng dừng) trong một cuộc trò chuyện. Nó cũng hỗ trợ sao chép giọng nói từ một đoạn âm thanh ngắn, cho phép tạo người nói zero-shot trong ngữ cảnh đối thoại.

  • Sesame CSM (Conversational Speech Model, 2025) tập trung vào phát ngôn đối thoại đa lượt tự nhiên. Thay vì tối ưu hóa cho TTS kiểu đọc (reading-style), Sesame mô hình hóa động thái của hội thoại thực: các tín hiệu phản hồi ("uh huh"), ngắt lời, thay đổi nhịp điệu giữa những người nói, và khả năng phản hồi cảm xúc. Mô hình dùng nhân transformer được điều kiện hóa bởi ngữ cảnh hội thoại (cả lịch sử văn bản và âm thanh), tạo ra tiếng nói thích ứng phong cách theo dòng chảy của cuộc đối thoại.

  • Fish Speech (Fish Audio, 2024) là một hệ thống TTS mã nguồn mở dùng kiến trúc tự hồi quy kép: một mô hình ngôn ngữ lớn sinh token ngữ nghĩa từ văn bản, và một mô hình nhỏ hơn chuyển đổi chúng thành token âm học VQGAN, sau đó được giải mã thành dạng sóng bởi một vocoder. Fish Speech hỗ trợ sao chép giọng nói zero-shot từ một đoạn tham chiếu 10–15 giây và đạt độ trễ thấp phù hợp cho các ứng dụng thời gian thực. Thiết kế mô-đun của nó cho phép hoán đổi các thành phần (ví dụ, các vocoder khác nhau) một cách độc lập.

  • ChatTTS (2024) là một mô hình TTS hội thoại mã nguồn mở được thiết kế cho các ứng dụng đối thoại như chatbot và trợ lý ảo. Nó tạo ra tiếng nói hội thoại tự nhiên với khả năng kiểm soát tinh tế các đặc trưng ngữ điệu (tiếng cười, khoảng dừng, từ đệm) bằng cách dùng các token đặc biệt nhúng trong văn bản đầu vào. ChatTTS hỗ trợ tổng hợp hai thứ tiếng Trung-Anh và sinh nhiều người nói.

  • Bark (Suno, 2023) là một mô hình mã nguồn mở dạng transformer sinh tiếng nói, âm nhạc, và hiệu ứng âm thanh từ đoạn văn bản. Nó dùng pipeline ba giai đoạn gồm các mô hình transformer (văn bản → token ngữ nghĩa → token âm học → token âm học tinh) và hỗ trợ sao chép giọng nói, tổng hợp đa ngữ, và âm thanh phi ngôn ngữ như nhạc và tiếng nền. Tính tổng quát của Bark đi kèm với cái giá là khả năng kiểm soát — nó kém chính xác hơn các hệ thống TTS chuyên dụng nhưng linh hoạt hơn.

  • Parler-TTS (Hugging Face, 2024) tiếp cận mô tả bằng ngôn ngữ tự nhiên để điều khiển giọng nói: thay vì yêu cầu một clip âm thanh tham chiếu cho phong cách, người dùng cung cấp một mô tả văn bản như "một người nói nữ với giọng ấm áp, biểu cảm, trong phòng yên tĩnh." Parler-TTS được huấn luyện trên dữ liệu tiếng nói có chú thích, nơi mỗi câu nói được ghép với một mô tả ngôn ngữ tự nhiên về phong cách nói, cho phép điều khiển trực quan mà không cần bất kỳ âm thanh tham chiếu nào.

  • Neuphonic là một nền tảng TTS dạng API được tối ưu hóa cho tổng hợp tiếng nói siêu thấp độ trễ, hướng đến các tác tử giọng nói thời gian thực và AI hội thoại. Nó đạt thời gian-tới-âm-thanh-đầu-tiên dưới 100 ms nhờ kiến trúc streaming bắt đầu sinh âm thanh trước khi toàn bộ văn bản đầu vào có sẵn. Neuphonic tập trung vào tầng tối ưu hóa triển khai và độ trễ hơn là kiến trúc mô hình mới.

  • KittenTTS là một mô hình TTS nhỏ gọn, nhanh được thiết kế cho hiệu quả và triển khai với ít tài nguyên. Nó ưu tiên độ trễ tối thiểu và kích thước mô hình nhỏ cho các ứng dụng biên và nhúng, đánh đổi một ít độ tự nhiên lấy hiệu suất thời gian thực trên CPU và thiết bị di động.

  • Bức tranh TTS hiện đại đang phân nhánh thành hai mô hình: (1) codec language model (VALL-E, Orpheus, Fish Speech) xem việc sinh tiếng nói như dự đoán token tiếp theo trên các mã âm thanh rời rạc, tận dụng các luật scaling của LLM; và (2) các mô hình luồng/khuếch tán (VITS, StyleTTS 2, Kokoro) sinh các mel spectrogram liên tục hoặc dạng sóng thông qua tinh chỉnh lặp. Codec LM xuất sắc ở sao chép zero-shot và biểu cảm; các mô hình luồng/khuếch tán thường nhỏ hơn và nhanh hơn. Cả hai đang nhanh chóng hội tụ về độ tự nhiên ngang người.

  • Mô hình hóa ngữ điệu (prosody modelling) điều khiển "âm nhạc" của tiếng nói: cao độ, thời lượng, năng lượng, nhịp điệu, và ngữ điệu. Không có ngữ điệu tốt, tiếng nói tổng hợp sẽ nghe đều đều và máy móc dù từng âm vị riêng lẻ rõ ràng. Hãy hình dung ngữ điệu như sự khác biệt giữa giọng GPS đều đều và một người thu truyện thể hiện cảm xúc.

  • Cao độ (tần số cơ bản \(F_0\)) là độ cao hay thấp của giọng nói. Nó tăng ở cuối câu hỏi, giảm ở cuối câu trần, và biến thiên liên tục trong tiếng nói cảm xúc. \(F_0\) được trích từ âm thanh dùng các thuật toán như CREPE (một bộ theo dõi cao độ nơ-ron) hoặc YIN (dựa trên tự tương quan, từ file 01). Trong TTS, cao độ hoặc được dự đoán bởi mô hình âm học (bộ dự đoán cao độ của FastSpeech 2) hoặc được học ngầm (Tacotron 2).

  • Thời lượng (duration) quyết định tốc độ nói và nhịp điệu. Các âm tiết nhấn mạnh dài hơn, từ chức năng bị rút ngắn, và khoảng dừng đánh dấu ranh giới cụm từ. Mô hình hóa thời lượng là rõ ràng trong các mô hình không tự hồi quy (FastSpeech) và ngầm định trong các mô hình tự hồi quy (sự căn chỉnh attention của Tacotron quyết định thời lượng).

  • Năng lượng (độ to) mang sự nhấn mạnh. "Tôi KHÔNG nói nó ĐÃ lấy cái đó" so với "Tôi không nói nó đã LẤY cái đó" có các ý nghĩa khác nhau được truyền tải hoàn toàn qua các mẫu năng lượng.

  • Phong cách nhúng (style embeddings) nắm bắt các mẫu ngữ điệu cấp cao hơn. Khung Global Style Token (GST) (Wang và đồng sự, 2018) học một ngân hàng các token phong cách (sự chú ý mềm trên một tập các embedding đã học) nắm bắt các phong cách nói như "hào hứng", "buồn", hoặc "thì thầm". Phong cách nhúng được trích từ một câu nói tham chiếu và được thêm vào đầu ra encoder, cho phép chuyển phong cách tại suy luận.

  • Voice conversion (VC) thay đổi danh tính người nói của một câu nói trong khi giữ nguyên nội dung ngôn ngữ. Hãy hình dung ghi âm giọng bạn và nghe đầu ra như thể của một người nói mục tiêu cụ thể. VC đòi hỏi tách biệt danh tính người nói khỏi nội dung.

Pipeline voice conversion: tiếng nói nguồn được phân tách thành biểu diễn nội dung và embedding người nói, embedding người nói mục tiêu thay thế nguồn, và decoder tái tạo tiếng nói trong giọng mục tiêu

  • Embedding người nói (speaker embeddings, chi tiết hơn ở file 04) mã hóa danh tính người nói thành một vector chiều cố định. Chúng có thể đến từ một mô hình xác minh người nói đã huấn luyện (x-vectors, ECAPA-TDNN). Trong VC, tiếng nói nguồn được mã hóa thành biểu diễn nội dung độc lập với người nói, sau đó được giải mã với embedding người nói mục tiêu.

  • Biểu diễn tách rời (disentangled representations) phân tách tiếng nói thành các yếu tố độc lập: nội dung (âm vị), danh tính người nói, cao độ, và nhịp điệu. Các cách tiếp cận bao gồm:

    • Nút cổ chai thông tin (information bottleneck): nén biểu diễn nội dung mạnh đến mức thông tin người nói bị mất (AutoVC)
    • Huấn luyện đối nghịch (adversarial training): huấn luyện một bộ phân lớp người nói trên biểu diễn nội dung và dùng đảo gradient để loại bỏ thông tin người nói
    • Vector lượng tử hóa (vector quantisation): VQ-VAE buộc nội dung qua nút cổ chai rời rạc, loại bỏ tự nhiên danh tính người nói (vì các mục trong bảng mã biểu diễn các danh mục ngữ âm, không phải đặc điểm người nói)
  • Voice cloning tổng hợp tiếng nói trong giọng của một người nói mục tiêu. TTS đa người nói (multi-speaker) huấn luyện trên dữ liệu của nhiều người nói, điều kiện hóa mô hình trên một embedding người nói. Khi suy luận, embedding của người nói mới được trích từ âm thanh đăng ký và dùng để điều kiện hóa quá trình sinh.

  • Voice cloning ít mẫu (few-shot voice cloning) thích ứng với người nói mới dùng một lượng nhỏ dữ liệu (vài phút). Bộ mã hóa người nói trích embedding từ âm thanh đăng ký, và mô hình TTS sinh tiếng nói được điều kiện hóa bởi embedding này. Đây là cách tiếp cận dùng trong SV2TTS (Jia và đồng sự, 2018): một bộ mã hóa người nói huấn luyện riêng, một bộ tổng hợp Tacotron 2 được điều kiện hóa bởi embedding người nói, và một vocoder WaveRNN.

  • Voice cloning zero-shot không yêu cầu bất kỳ sự thích ứng nào: một câu nói ngắn duy nhất (3–30 giây) là đủ. VALL-E đạt được điều này bằng cách xem âm thanh đăng ký như một prompt cho mô hình ngôn ngữ. Mô hình học cách tiếp tục sinh với cùng giọng nói vì nó được huấn luyện trên dữ liệu đa người nói quy mô lớn, nơi tính nhất quán của giọng trong một câu nói là chuẩn thống kê.

  • Phát hiện hoạt động giọng nói (Voice activity detection — VAD) trả lời một câu hỏi nhị phân đơn giản tại mỗi khung thời gian: có ai đang nói hay không? Dù đơn giản, VAD là bước tiền xử lý quan trọng cho ASR (file 02), phân tách người nói (file 04), và giảm nhiễu (file 05). Một VAD tốt giảm tính toán bằng cách bỏ qua khoảng lặng và cải thiện độ chính xác bằng cách ngăn nhiễu được xử lý như tiếng nói.

  • VAD cổ điển dùng ngưỡng năng lượng (tiếng nói to hơn khoảng lặng), tỷ lệ qua không (tiếng nói có các mẫu qua không đặc trưng), và các đặc trưng phổ. Các phương pháp này thất bại trong môi trường nhiễu có tỷ lệ tín hiệu-trên-nhiễu thấp.

  • VAD nơ-ron xem bài toán như phân lớp nhị phân mức khung. Một RNN hoặc CNN nhỏ nhận các đặc trưng âm học (năng lượng log-mel từ file 01) và dự đoán xác suất tiếng nói/không tiếng nói.

  • WebRTC VAD (Google) là một VAD nhẹ cổ điển dùng bộ phân loại dựa trên GMM trên các đặc trưng phổ đơn giản. Nó hoạt động ở bốn mức độ mạnh mẽ (0–3) và cực kỳ nhanh, nhưng gặp khó khăn với âm nhạc, các âm thanh phi ngôn ngữ, và môi trường SNR thấp. Nó vẫn được sử dụng rộng rãi như một baseline nhờ tính đơn giản không phụ thuộc.

  • Silero VAD (Silero Team, 2021) là VAD nơ-ron thực tế tiêu chuẩn cho sản xuất. Kiến trúc của nó là một chồng nhỏ các tích chập 1D phân tách chiều sâu (depthwise separable convolutions — ý tưởng MobileNet ở chương 08 áp dụng lên âm thanh) theo sau bởi một lớp LSTM duy nhất cho ngữ cảnh thời gian, với một đầu tuyến tính cuối tạo ra xác suất tiếng nói mỗi khung. Toàn bộ mô hình dưới 2MB (~1M tham số) và xử lý âm thanh trong các khúc 30–100 ms.

    • Đầu vào: âm thanh thô 16 kHz (không cần trích đặc trưng thủ công — đầu tích chập học các đặc trưng của riêng nó trực tiếp từ dạng sóng).
    • Suy luận trạng thái theo cửa sổ (windowed stateful inference): trạng thái ẩn LSTM được mang qua giữa các khúc, do đó mô hình xử lý âm thanh streaming mà không cần xử lý lại toàn bộ lịch sử. Mỗi lần gọi xử lý một khúc 30, 60, hoặc 100 ms và trả về xác suất tiếng nói trong \([0, 1]\).
    • Ngưỡng thích ứng: thay vì một ngưỡng cố định duy nhất, Silero VAD dùng ngưỡng bắt đầu và kết thúc riêng biệt với thời lượng tiếng nói/khoảng lặng tối thiểu, ngăn chuyển đổi nhanh trên ranh giới nhiễu. Một đoạn tiếng nói phải vượt ngưỡng bắt đầu trong một thời lượng tối thiểu trước khi được xác nhận, và khoảng lặng phải kéo dài dưới ngưỡng kết thúc trước khi đoạn được đóng.
    • Hiệu suất: Silero VAD chạy ở 1–2% hệ số thời gian thực trên CPU (xử lý 1 giây âm thanh mất ~10–20 ms), làm nó phù hợp cho thiết bị biên, điện thoại di động, và pipeline thời gian thực. Nó vượt trội đáng kể so với WebRTC VAD trên âm thanh nhiễu và nhiều nhạc trong khi vẫn đủ nhỏ để triển khai trên thiết bị.
    • Silero VAD thường được dùng làm front-end cho Whisper (file 02) để phân đoạn âm thanh dài thành các đoạn mức câu nói trước khi phiên âm, và cho các pipeline phân tách người nói (file 04) để xác định vùng tiếng nói trước khi trích embedding người nói.
  • Phát hiện hoạt động âm thanh (Acoustic activity detection — AAD) tổng quát hóa VAD để phát hiện bất kỳ hoạt động âm thanh nào, không chỉ tiếng nói. Điều này hữu ích trong các thiết bị nhà thông minh, hệ thống an ninh, và giám sát động vật hoang dã. Các mô hình AAD phát hiện các sự kiện như vỡ kính, chó sủa, hoặc báo động, thường dùng các khung phân loại âm thanh mô tả trong file 04.

  • Các metric đánh giá cho TTS đo cả chất lượng khách quan và độ tự nhiên chủ quan:

    • Điểm đánh giá trung bình (Mean Opinion Score — MOS): người nghe đánh giá độ tự nhiên trên thang 1-5. Tiêu chuẩn vàng, nhưng tốn kém và chậm.
    • Mel cepstral distortion (MCD): đo khoảng cách giữa mel cepstra tổng hợp và tham chiếu. Thấp hơn thì tốt hơn, nhưng không phải lúc nào cũng tương quan với cảm nhận.
    • PESQ / POLQA: các metric đánh giá cảm quan chuẩn hóa ban đầu được thiết kế cho điện thoại.
    • Độ tương tự người nói (speaker similarity): độ tương tự cosine giữa embedding người nói của âm thanh tổng hợp và tham chiếu (liên quan đến voice cloning).
    • Độ dễ hiểu (intelligibility): đo bằng cách đưa âm thanh tổng hợp qua một hệ thống ASR (file 02) và tính WER.

Bài tập lập trình (dùng CoLab hoặc notebook)

  • Bài 1: Vocoder Griffin-Lim từ mel spectrogram. Cài đặt thuật toán tái tạo pha lặp Griffin-Lim để chuyển đổi mel spectrogram trở lại thành dạng sóng. Điều này minh họa bài toán vocoder và lý do vocoder nơ-ron là cần thiết.
import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt

# Generate a synthetic waveform (sum of harmonics simulating a vowel)
sr = 16000
duration = 1.0
t = jnp.linspace(0, duration, int(sr * duration))
f0 = 220.0  # fundamental frequency
waveform = (
    0.6 * jnp.sin(2 * jnp.pi * f0 * t) +
    0.3 * jnp.sin(2 * jnp.pi * 2 * f0 * t) +
    0.1 * jnp.sin(2 * jnp.pi * 3 * f0 * t)
)

# Compute STFT
n_fft = 1024
hop_length = 256
window = jnp.hanning(n_fft)

def stft(signal, n_fft, hop_length, window):
    """Compute Short-Time Fourier Transform."""
    n_frames = 1 + (len(signal) - n_fft) // hop_length
    frames = jnp.stack([
        signal[i * hop_length : i * hop_length + n_fft] * window
        for i in range(n_frames)
    ])
    return jnp.fft.rfft(frames, n=n_fft)

def istft(stft_matrix, hop_length, window, length):
    """Compute inverse STFT with overlap-add."""
    n_fft = (stft_matrix.shape[1] - 1) * 2
    n_frames = stft_matrix.shape[0]
    frames = jnp.fft.irfft(stft_matrix, n=n_fft)
    frames = frames * window[None, :]
    output = jnp.zeros(length)
    for i in range(n_frames):
        start = i * hop_length
        end = start + n_fft
        if end <= length:
            output = output.at[start:end].add(frames[i])
    return output

# Forward STFT
S = stft(waveform, n_fft, hop_length, window)
magnitude = jnp.abs(S)

# Mel filterbank
n_mels = 80
mel_low = 0.0
mel_high = 2595 * jnp.log10(1 + (sr / 2) / 700)
mel_points = jnp.linspace(mel_low, mel_high, n_mels + 2)
hz_points = 700 * (10 ** (mel_points / 2595) - 1)
freq_bins = jnp.floor((n_fft + 1) * hz_points / sr).astype(int)

mel_filterbank = jnp.zeros((n_mels, n_fft // 2 + 1))
for m in range(n_mels):
    f_left = freq_bins[m]
    f_center = freq_bins[m + 1]
    f_right = freq_bins[m + 2]
    for k in range(f_left, f_center):
        mel_filterbank = mel_filterbank.at[m, k].set(
            (k - f_left) / max(f_center - f_left, 1)
        )
    for k in range(f_center, f_right):
        mel_filterbank = mel_filterbank.at[m, k].set(
            (f_right - k) / max(f_right - f_center, 1)
        )

# To mel and back (pseudo-inverse)
mel_spec = magnitude @ mel_filterbank.T
magnitude_reconstructed = mel_spec @ jnp.linalg.pinv(mel_filterbank.T)
magnitude_reconstructed = jnp.maximum(magnitude_reconstructed, 1e-7)

# Griffin-Lim algorithm
def griffin_lim(magnitude, n_iter, hop_length, window, signal_length):
    """Iterative phase reconstruction."""
    n_fft = (magnitude.shape[1] - 1) * 2
    key = jax.random.PRNGKey(42)
    phase = jax.random.uniform(key, magnitude.shape, minval=-jnp.pi, maxval=jnp.pi)

    for _ in range(n_iter):
        complex_spec = magnitude * jnp.exp(1j * phase)
        signal = istft(complex_spec, hop_length, window, signal_length)
        reanalysis = stft(signal, n_fft, hop_length, window)
        phase = jnp.angle(reanalysis)

    complex_spec = magnitude * jnp.exp(1j * phase)
    return istft(complex_spec, hop_length, window, signal_length)

reconstructed = griffin_lim(magnitude_reconstructed, n_iter=60, hop_length=hop_length,
                            window=window, signal_length=len(waveform))

# Plot comparison
fig, axes = plt.subplots(3, 1, figsize=(12, 8))

axes[0].plot(t[:1000], waveform[:1000], color='#3498db', linewidth=0.8)
axes[0].set_title('Original Waveform')
axes[0].set_ylabel('Amplitude')

axes[1].imshow(jnp.log1p(mel_spec.T), aspect='auto', origin='lower', cmap='magma')
axes[1].set_title('Mel Spectrogram (intermediate representation)')
axes[1].set_ylabel('Mel bin')

axes[2].plot(t[:1000], reconstructed[:1000], color='#e74c3c', linewidth=0.8)
axes[2].set_title('Griffin-Lim Reconstructed Waveform (60 iterations)')
axes[2].set_xlabel('Time (s)')
axes[2].set_ylabel('Amplitude')

plt.tight_layout()
plt.show()

# Measure reconstruction error
mse = jnp.mean((waveform[:len(reconstructed)] - reconstructed[:len(waveform)]) ** 2)
print(f"MSE between original and reconstructed: {mse:.6f}")
print("Note: phase information loss through mel inversion causes artifacts.")
  • Bài 2: Bộ dự đoán thời lượng (theo phong cách FastSpeech). Huấn luyện một bộ dự đoán thời lượng tích chập nhỏ ánh xạ các embedding âm vị sang thời lượng. Đây là thành phần cốt lõi cho phép TTS không tự hồi quy.
import jax
import jax.numpy as jnp
import jax.random as jr
import matplotlib.pyplot as plt

# Simulate phoneme sequences with ground-truth durations
# In real TTS, durations come from forced alignment or teacher attention
def generate_synthetic_data(key, n_samples=200, max_phonemes=30, embed_dim=64):
    """Generate synthetic phoneme embeddings and durations."""
    keys = jr.split(key, 4)
    lengths = jr.randint(keys[0], (n_samples,), 5, max_phonemes)

    all_embeddings = []
    all_durations = []
    all_masks = []

    for i in range(n_samples):
        L = int(lengths[i])
        emb = jr.normal(keys[1], (max_phonemes, embed_dim))
        # Durations: vowels (even indices) are longer, consonants shorter
        base_dur = jnp.where(jnp.arange(max_phonemes) % 2 == 0, 8.0, 4.0)
        noise = jr.normal(jr.fold_in(keys[2], i), (max_phonemes,)) * 1.5
        dur = jnp.clip(base_dur + noise, 1.0, 20.0).astype(jnp.float32)
        mask = (jnp.arange(max_phonemes) < L).astype(jnp.float32)

        all_embeddings.append(emb)
        all_durations.append(dur * mask)
        all_masks.append(mask)

    return (jnp.stack(all_embeddings), jnp.stack(all_durations),
            jnp.stack(all_masks))

key = jr.PRNGKey(42)
embeddings, durations, masks = generate_synthetic_data(key)

# Duration predictor: 2-layer 1D convolution + linear projection
def init_duration_predictor(key, embed_dim=64, hidden_dim=128, kernel_size=3):
    """Initialise duration predictor weights."""
    keys = jr.split(key, 4)
    scale1 = jnp.sqrt(2.0 / (embed_dim * kernel_size))
    scale2 = jnp.sqrt(2.0 / (hidden_dim * kernel_size))
    params = {
        'conv1_w': jr.normal(keys[0], (kernel_size, embed_dim, hidden_dim)) * scale1,
        'conv1_b': jnp.zeros(hidden_dim),
        'conv2_w': jr.normal(keys[1], (kernel_size, hidden_dim, hidden_dim)) * scale2,
        'conv2_b': jnp.zeros(hidden_dim),
        'linear_w': jr.normal(keys[2], (hidden_dim, 1)) * jnp.sqrt(2.0 / hidden_dim),
        'linear_b': jnp.zeros(1),
    }
    return params

def duration_predictor(params, x):
    """Predict log-durations from phoneme embeddings. x: (batch, seq, embed)."""
    # Conv layer 1 with ReLU
    h = jax.lax.conv_general_dilated(
        x.transpose(0, 2, 1),  # (batch, embed, seq)
        params['conv1_w'].transpose(2, 1, 0),  # (out, in, kernel)
        window_strides=(1,), padding='SAME'
    ).transpose(0, 2, 1) + params['conv1_b']  # back to (batch, seq, hidden)
    h = jax.nn.relu(h)

    # Conv layer 2 with ReLU
    h = jax.lax.conv_general_dilated(
        h.transpose(0, 2, 1),
        params['conv2_w'].transpose(2, 1, 0),
        window_strides=(1,), padding='SAME'
    ).transpose(0, 2, 1) + params['conv2_b']
    h = jax.nn.relu(h)

    # Linear projection to scalar
    log_dur = (h @ params['linear_w'] + params['linear_b']).squeeze(-1)
    return log_dur

# Loss: MSE on log-durations (standard in FastSpeech)
def loss_fn(params, embeddings, durations, masks):
    log_dur_pred = duration_predictor(params, embeddings)
    log_dur_true = jnp.log(jnp.clip(durations, 1.0, None))
    sq_err = (log_dur_pred - log_dur_true) ** 2 * masks
    return jnp.sum(sq_err) / jnp.sum(masks)

grad_fn = jax.jit(jax.value_and_grad(loss_fn))

# Training loop
params = init_duration_predictor(jr.PRNGKey(0))
lr = 1e-3
losses = []

for epoch in range(300):
    loss_val, grads = grad_fn(params, embeddings, durations, masks)
    params = jax.tree.map(lambda p, g: p - lr * g, params, grads)
    losses.append(float(loss_val))

# Evaluate on a sample
log_dur_pred = duration_predictor(params, embeddings[:1])
dur_pred = jnp.exp(log_dur_pred[0])
dur_true = durations[0]
mask = masks[0]
valid_len = int(jnp.sum(mask))

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

axes[0].plot(losses, color='#3498db', linewidth=1.5)
axes[0].set_xlabel('Epoch')
axes[0].set_ylabel('MSE Loss (log-duration)')
axes[0].set_title('Duration Predictor Training')
axes[0].set_yscale('log')

x_pos = jnp.arange(valid_len)
width = 0.35
axes[1].bar(x_pos - width/2, dur_true[:valid_len], width, color='#27ae60',
            label='Ground truth', alpha=0.8)
axes[1].bar(x_pos + width/2, dur_pred[:valid_len], width, color='#e74c3c',
            label='Predicted', alpha=0.8)
axes[1].set_xlabel('Phoneme index')
axes[1].set_ylabel('Duration (frames)')
axes[1].set_title('Duration Prediction vs Ground Truth')
axes[1].legend()

plt.tight_layout()
plt.show()
  • Bài 3: Vocoder nơ-ron đơn giản với upsampling convolutions. Xây dựng một generator tối thiểu kiểu HiFi-GAN upsampling mel spectrogram thành dạng sóng dùng tích chập chuyển vị và các khối dư.
import jax
import jax.numpy as jnp
import jax.random as jr
import matplotlib.pyplot as plt

def init_residual_block(key, channels, kernel_size, dilation):
    """Initialise a dilated residual convolution block."""
    k1, k2 = jr.split(key)
    scale = jnp.sqrt(2.0 / (channels * kernel_size))
    return {
        'conv1_w': jr.normal(k1, (kernel_size, channels, channels)) * scale,
        'conv1_b': jnp.zeros(channels),
        'conv2_w': jr.normal(k2, (kernel_size, channels, channels)) * scale,
        'conv2_b': jnp.zeros(channels),
        'dilation': dilation
    }

def residual_block(params, x):
    """x: (batch, time, channels). Dilated conv residual block with LeakyReLU."""
    h = jax.nn.leaky_relu(x, negative_slope=0.1)
    # Simplified: use standard conv (dilation handled conceptually)
    h = jax.lax.conv_general_dilated(
        h.transpose(0, 2, 1),
        params['conv1_w'].transpose(2, 1, 0),
        window_strides=(1,),
        padding='SAME',
        rhs_dilation=(params['dilation'],)
    ).transpose(0, 2, 1) + params['conv1_b']
    h = jax.nn.leaky_relu(h, negative_slope=0.1)
    h = jax.lax.conv_general_dilated(
        h.transpose(0, 2, 1),
        params['conv2_w'].transpose(2, 1, 0),
        window_strides=(1,),
        padding='SAME'
    ).transpose(0, 2, 1) + params['conv2_b']
    return x + h

def init_generator(key, n_mels=80, upsample_rates=(8, 8, 4),
                   channels=128):
    """Initialise a minimal HiFi-GAN-style generator."""
    keys = jr.split(key, 10)
    params = {}

    # Input projection: mel bins -> channels
    params['input_w'] = jr.normal(keys[0], (7, n_mels, channels)) * 0.02
    params['input_b'] = jnp.zeros(channels)

    # Upsample blocks (transposed convolutions)
    in_ch = channels
    for i, rate in enumerate(upsample_rates):
        k_size = rate * 2
        scale = jnp.sqrt(2.0 / (in_ch * k_size))
        out_ch = in_ch // 2
        params[f'up{i}_w'] = jr.normal(keys[i+1], (k_size, in_ch, out_ch)) * scale
        params[f'up{i}_b'] = jnp.zeros(out_ch)
        # Residual blocks at each scale
        params[f'res{i}_0'] = init_residual_block(jr.fold_in(keys[i+4], 0),
                                                    out_ch, 3, 1)
        params[f'res{i}_1'] = init_residual_block(jr.fold_in(keys[i+4], 1),
                                                    out_ch, 3, 3)
        in_ch = out_ch

    # Output projection to mono waveform
    params['output_w'] = jr.normal(keys[8], (7, in_ch, 1)) * 0.02
    params['output_b'] = jnp.zeros(1)
    params['upsample_rates'] = upsample_rates

    return params

def generator_forward(params, mel):
    """mel: (batch, time, n_mels) -> waveform: (batch, time * prod(rates), 1)."""
    # Input projection
    h = jax.lax.conv_general_dilated(
        mel.transpose(0, 2, 1),
        params['input_w'].transpose(2, 1, 0),
        window_strides=(1,), padding='SAME'
    ).transpose(0, 2, 1) + params['input_b']

    for i, rate in enumerate(params['upsample_rates']):
        h = jax.nn.leaky_relu(h, negative_slope=0.1)
        # Upsample via transposed convolution
        k_size = rate * 2
        h = jax.lax.conv_transpose(
            h.transpose(0, 2, 1),
            params[f'up{i}_w'].transpose(2, 1, 0),
            strides=(rate,),
            padding='SAME'
        ).transpose(0, 2, 1) + params[f'up{i}_b']
        # Residual blocks
        h = residual_block(params[f'res{i}_0'], h)
        h = residual_block(params[f'res{i}_1'], h)

    h = jax.nn.leaky_relu(h, negative_slope=0.1)
    out = jax.lax.conv_general_dilated(
        h.transpose(0, 2, 1),
        params['output_w'].transpose(2, 1, 0),
        window_strides=(1,), padding='SAME'
    ).transpose(0, 2, 1) + params['output_b']

    return jnp.tanh(out)

# Create a synthetic mel spectrogram (simulating a vowel)
n_mels = 80
n_frames = 50
mel = jnp.zeros((1, n_frames, n_mels))
# Add energy in low-frequency mel bins (simulating formants)
mel = mel.at[:, :, 5:15].set(1.0)
mel = mel.at[:, :, 20:25].set(0.6)

# Initialise and run generator
key = jr.PRNGKey(42)
params = init_generator(key, n_mels=n_mels, upsample_rates=(8, 8, 4),
                         channels=128)
waveform = generator_forward(params, mel)

print(f"Input mel shape:  {mel.shape}")
print(f"Output waveform shape: {waveform.shape}")
print(f"Upsample factor: {8 * 8 * 4} = {8*8*4}x")

fig, axes = plt.subplots(2, 1, figsize=(12, 6))

axes[0].imshow(mel[0].T, aspect='auto', origin='lower', cmap='magma')
axes[0].set_title('Input Mel Spectrogram')
axes[0].set_ylabel('Mel bin')
axes[0].set_xlabel('Frame')

waveform_np = waveform[0, :, 0]
axes[1].plot(waveform_np[:2000], color='#9b59b6', linewidth=0.5)
axes[1].set_title('Generator Output Waveform (untrained - random noise)')
axes[1].set_ylabel('Amplitude')
axes[1].set_xlabel('Sample')

plt.tight_layout()
plt.show()
print("Note: The output is noise because the generator is untrained.")
print("In practice, adversarial + mel loss training shapes this into speech.")
  • Bài 4: Phát hiện hoạt động giọng nói với một RNN đơn giản. Huấn luyện một mô hình VAD dựa trên GRU nhỏ trên các đặc trưng âm thanh tổng hợp để phân loại khung là tiếng nói hay khoảng lặng.
import jax
import jax.numpy as jnp
import jax.random as jr
import matplotlib.pyplot as plt

# Generate synthetic log-mel energy features with speech/silence labels
def generate_vad_data(key, n_sequences=100, n_frames=200, n_features=40):
    """Simulate log-mel features: speech regions are higher energy with structure."""
    keys = jr.split(key, 5)
    all_features = []
    all_labels = []

    for i in range(n_sequences):
        k = jr.fold_in(keys[0], i)
        k1, k2, k3 = jr.split(k, 3)

        # Random speech/silence pattern
        label = jnp.zeros(n_frames)
        n_segments = jr.randint(k1, (), 2, 6)
        for seg in range(int(n_segments)):
            start = jr.randint(jr.fold_in(k2, seg), (), 0, n_frames - 20)
            length = jr.randint(jr.fold_in(k3, seg), (), 10, 50)
            end = jnp.minimum(start + length, n_frames)
            label = label.at[int(start):int(end)].set(1.0)

        # Features: speech frames have higher energy + spectral structure
        noise = jr.normal(jr.fold_in(keys[1], i), (n_frames, n_features)) * 0.3
        speech_pattern = jnp.outer(label, jnp.exp(-jnp.arange(n_features) / 15.0))
        features = speech_pattern * 2.0 + noise + 0.1

        all_features.append(features)
        all_labels.append(label)

    return jnp.stack(all_features), jnp.stack(all_labels)

key = jr.PRNGKey(123)
features, labels = generate_vad_data(key)
train_features, train_labels = features[:80], labels[:80]
test_features, test_labels = features[80:], labels[80:]

# Simple GRU-based VAD model
def init_vad_model(key, input_dim=40, hidden_dim=64):
    keys = jr.split(key, 6)
    scale_ih = jnp.sqrt(2.0 / input_dim)
    scale_hh = jnp.sqrt(2.0 / hidden_dim)
    return {
        'W_z': jr.normal(keys[0], (input_dim, hidden_dim)) * scale_ih,
        'U_z': jr.normal(keys[1], (hidden_dim, hidden_dim)) * scale_hh,
        'b_z': jnp.zeros(hidden_dim),
        'W_r': jr.normal(keys[2], (input_dim, hidden_dim)) * scale_ih,
        'U_r': jr.normal(keys[3], (hidden_dim, hidden_dim)) * scale_hh,
        'b_r': jnp.zeros(hidden_dim),
        'W_h': jr.normal(keys[4], (input_dim, hidden_dim)) * scale_ih,
        'U_h': jr.normal(keys[5], (hidden_dim, hidden_dim)) * scale_hh,
        'b_h': jnp.zeros(hidden_dim),
        'W_out': jr.normal(jr.fold_in(keys[0], 99), (hidden_dim, 1)) * 0.1,
        'b_out': jnp.zeros(1),
    }

def gru_step(params, h, x):
    """Single GRU step."""
    z = jax.nn.sigmoid(x @ params['W_z'] + h @ params['U_z'] + params['b_z'])
    r = jax.nn.sigmoid(x @ params['W_r'] + h @ params['U_r'] + params['b_r'])
    h_tilde = jnp.tanh(x @ params['W_h'] + (r * h) @ params['U_h'] + params['b_h'])
    h_new = (1 - z) * h + z * h_tilde
    return h_new

def vad_forward(params, x):
    """x: (batch, time, features) -> logits: (batch, time)."""
    batch_size, n_frames, _ = x.shape
    hidden_dim = params['W_z'].shape[1]
    h = jnp.zeros((batch_size, hidden_dim))

    outputs = []
    for t in range(n_frames):
        h = gru_step(params, h, x[:, t, :])
        logit = (h @ params['W_out'] + params['b_out']).squeeze(-1)
        outputs.append(logit)

    return jnp.stack(outputs, axis=1)

def bce_loss(params, features, labels):
    """Binary cross-entropy loss for VAD."""
    logits = vad_forward(params, features)
    probs = jax.nn.sigmoid(logits)
    probs = jnp.clip(probs, 1e-7, 1 - 1e-7)
    loss = -(labels * jnp.log(probs) + (1 - labels) * jnp.log(1 - probs))
    return jnp.mean(loss)

grad_fn = jax.jit(jax.value_and_grad(bce_loss))

# Training
params = init_vad_model(jr.PRNGKey(0))
lr = 5e-3
losses = []

for epoch in range(200):
    loss_val, grads = grad_fn(params, train_features, train_labels)
    params = jax.tree.map(lambda p, g: p - lr * g, params, grads)
    losses.append(float(loss_val))
    if epoch % 50 == 0:
        print(f"Epoch {epoch}: loss = {loss_val:.4f}")

# Evaluate on test set
test_logits = vad_forward(params, test_features)
test_preds = (jax.nn.sigmoid(test_logits) > 0.5).astype(jnp.float32)
accuracy = jnp.mean(test_preds == test_labels)
print(f"\nTest accuracy: {accuracy:.4f}")

# Visualise a test example
idx = 0
fig, axes = plt.subplots(3, 1, figsize=(14, 7))

axes[0].imshow(test_features[idx].T, aspect='auto', origin='lower', cmap='magma')
axes[0].set_title('Log-Mel Energy Features')
axes[0].set_ylabel('Mel bin')

axes[1].fill_between(range(200), test_labels[idx], alpha=0.4, color='#27ae60',
                     label='Ground truth')
axes[1].plot(jax.nn.sigmoid(test_logits[idx]), color='#e74c3c',
             linewidth=1.5, label='Predicted probability')
axes[1].axhline(0.5, color='gray', linestyle='--', linewidth=0.8)
axes[1].set_ylabel('Speech probability')
axes[1].legend()
axes[1].set_title('VAD Predictions')

axes[2].fill_between(range(200), test_labels[idx], alpha=0.4, color='#27ae60',
                     label='Ground truth')
axes[2].fill_between(range(200), test_preds[idx], alpha=0.4, color='#f39c12',
                     label='Predicted (threshold=0.5)')
axes[2].set_ylabel('Speech / Silence')
axes[2].set_xlabel('Frame')
axes[2].legend()
axes[2].set_title('VAD Binary Decision')

plt.tight_layout()
plt.show()