Chuyển giọng nói thành văn bản là bài toán quen thuộc với doanh nghiệp: ghi âm tổng đài, biên bản cuộc họp, phụ đề video hướng dẫn. Từ cuối 2022, Whisper của OpenAI biến bài toán này thành thứ có thể chạy ngay trên laptop mà không cần gửi file ghi âm ra ngoài. Bài này chạy Whisper trên CPU của một máy Apple Silicon, đo độ chính xác (WER) và tốc độ (RTF) trên tiếng Việt, so openai-whisper với faster-whisper, rồi đo từng tuỳ chọn tối ưu: lượng tử hoá int8, beam size, lọc im lặng bằng VAD. Cuối bài là các thí nghiệm về nhiễu và về chuyện mô hình "bịa" chữ khi gặp khoảng lặng, đúng thứ gây sự cố khi đưa vào sản phẩm thật.

Bài dùng kiến thức từ Transformer và cơ chế attention: đọc hiểu kiến trúc qua code (Whisper là Transformer encoder–decoder) và cách đo ở Đánh giá mô hình phân loại: precision, recall, ROC và dữ liệu lệch. Môi trường: Python 3.12, openai-whisper 20231117, faster-whisper 1.0.2, ctranslate2 4.2.1, torch 2.3.0, chạy CPU, mô hình base và small. Mọi con số là output thật của máy này; máy khác sẽ lệch thời gian, nhưng tỉ lệ giữa các cấu hình ổn định hơn nhiều.

Whisper là gì và vì sao chạy local

Theo bài báo Robust Speech Recognition via Large-Scale Weak Supervision (Radford và cộng sự, 2022), Whisper được huấn luyện để đoán phụ đề trên 680.000 giờ âm thanh thu từ internet, đa ngôn ngữ và đa tác vụ, mà không cần tinh chỉnh riêng cho từng bộ dữ liệu. Thẻ mô hình trên Hugging Face ghi rõ cơ cấu dữ liệu: khoảng 65% (438.000 giờ) là tiếng Anh, 18% (126.000 giờ) là âm thanh ngoài tiếng Anh kèm phụ đề tiếng Anh, và 17% (117.000 giờ) là âm thanh ngoài tiếng Anh kèm phụ đề cùng ngôn ngữ, trải trên 98 ngôn ngữ. Nghĩa là tiếng Việt chỉ nhận một phần rất nhỏ của 117.000 giờ đó, và chất lượng sẽ kém tiếng Anh. Bài đo ở dưới cho thấy chênh lệch này bằng số cụ thể.

Kiến trúc là Transformer encoder–decoder: audio được cắt thành cửa sổ 30 giây, đổi sang log-Mel spectrogram, encoder mã hoá, decoder sinh từng token văn bản. Tài liệu của kho openai/whisper nêu transcribe() đọc cả file và xử lý bằng cửa sổ trượt 30 giây, mỗi cửa sổ chạy dự đoán tự hồi quy. Các cỡ mô hình đa ngôn ngữ trong bảng của kho này:

CỡTham sốVRAM cần (theo tài liệu)Tốc độ tương đối
tiny39 triệu~1 GB~10x
base74 triệu~1 GB~7x
small244 triệu~2 GB~4x
medium769 triệu~5 GB~2x
large1.550 triệu~10 GB1x

Tốc độ so với large, đo trên A100. Bài này chỉ chạy base và small vì giới hạn RAM của máy thử và vì mục tiêu là học cách đo; medium trở lên không chạy trong bài.

Lý do chạy local: ghi âm chứa thông tin khách hàng không nên rời hạ tầng, và chi phí không tính theo phút. Cái giá là bạn tự đo chất lượng và tối ưu tốc độ.

Chuẩn bị dữ liệu thử có đáp án chuẩn

Muốn tính WER phải có bản chép đúng. Để không dùng giọng người thật hay dữ liệu cá nhân, tôi tạo sáu câu tiếng Việt kiểu chăm sóc khách hàng và đọc bằng giọng tổng hợp có sẵn của macOS (say -v Linh), đổi sang WAV 16 kHz mono 16-bit (make_audio.py):

SENTS = [
    "Xin chào quý khách, đơn hàng của anh đã được xác nhận và sẽ giao trong hai ngày tới.",
    "Cửa hàng mở cửa từ tám giờ sáng đến chín giờ tối, kể cả thứ bảy và chủ nhật.",
    # ... thêm bốn câu về thanh toán, bảo hành, tin nhắn xác nhận, hỗ trợ kỹ thuật
]
for i, s in enumerate(SENTS, 1):
    subprocess.run(["say", "-v", "Linh", "-o", f"audio/s{i}.aiff", s], check=True)
    subprocess.run(["afconvert", "-f", "WAVE", "-d", "LEI16@16000", "-c", "1",
                    f"audio/s{i}.aiff", f"audio/s{i}.wav"], check=True)

Tổng cộng 34,8 giây âm thanh. Cần nói thẳng giới hạn: giọng tổng hợp sạch, đều, không tạp âm, không tiếng địa phương. Nó cho phép so các cấu hình với nhau trên cùng đầu vào, nhưng WER tuyệt đối ở đây không đại diện cho ghi âm tổng đài thật. Với dự án thật, hãy gom vài chục phút ghi âm của chính bạn (đã được phép) và chép tay làm đáp án.

Hàm chuẩn hoá (chữ thường, NFC, bỏ dấu câu) cũng ảnh hưởng WER vì Whisper tự viết hoa và chấm câu.

def norm(s):
    s = unicodedata.normalize("NFC", s.lower())
    s = re.sub(r"[^\w\s]", " ", s)
    return re.sub(r"\s+", " ", s).strip()

wer = jiwer.wer([norm(v) for v in refs.values()], [norm(h) for h in hyps])

Chạy Whisper bằng openai-whisper và faster-whisper

Hai cách chạy cùng một bộ trọng số. openai-whisper là bản tham chiếu viết bằng PyTorch. faster-whisper cài lại mô hình trên CTranslate2, một engine suy luận cho Transformer; README của dự án nói nhanh hơn tới 4 lần ở cùng độ chính xác, ít RAM hơn, và lượng tử hoá 8 bit tăng thêm hiệu quả trên cả CPU lẫn GPU. Hai đoạn gọi tối thiểu (transcribe_compare.py):

# openai-whisper (PyTorch, fp32, CPU)
import whisper
m = whisper.load_model("small", device="cpu")
text = m.transcribe(audio, language="vi", fp16=False, beam_size=5, temperature=0.0)["text"]

# faster-whisper (CTranslate2, int8, CPU)
from faster_whisper import WhisperModel
m = WhisperModel("small", device="cpu", compute_type="int8")
segs, info = m.transcribe(audio, language="vi", beam_size=5, temperature=0.0)
text = " ".join(s.text for s in segs)

Lưu ý: language="vi" bỏ qua bước tự đoán ngôn ngữ. Ngoài ra, transcribe() của faster-whisper trả về một generator: việc giải mã chỉ chạy khi bạn lặp qua segs, nên nếu đo thời gian mà không duyệt generator thì ra con số đẹp một cách giả tạo. Trong mã đo, tôi nối văn bản bên trong đoạn được tính giờ.

Kết quả trên 34,8 giây audio, model small (out_small.txt):

model=small  số file=6  tổng audio=34.8s
[openai-whisper fp32 CPU] load=1.9s  suy luận=33.8s  RTF=0.971  WER=9.6%  CER=6.5%
[faster-whisper int8 CPU] load=2.4s  suy luận=15.8s  RTF=0.455  WER=9.6%  CER=6.5%

Với model base (out_base.txt):

model=base  số file=6  tổng audio=34.8s
[openai-whisper fp32 CPU] load=1.2s  suy luận=14.4s  RTF=0.413  WER=16.5%  CER=8.1%
[faster-whisper int8 CPU] load=2.1s  suy luận=7.0s  RTF=0.200  WER=14.8%  CER=7.3%

RTF (real-time factor) là thời gian xử lý chia độ dài audio; dưới 1 nghĩa là nhanh hơn thời gian thực. Tóm lại:

  • Model small: faster-whisper int8 nhanh gấp khoảng 2,1 lần (RTF 0,455 so với 0,971) và WER giống hệt 9,6%.
  • Model base: nhanh gấp khoảng 2,1 lần (RTF 0,200 so với 0,413); WER thậm chí thấp hơn chút (14,8% so với 16,5%) nhờ khác biệt nhỏ ở cách giải mã, đừng đọc thành "int8 chính xác hơn".
  • Từ base lên small, WER giảm từ 14,8% xuống 9,6% nhưng thời gian hơn gấp đôi (khoảng 2,3 lần: 7,0 giây lên 15,8 giây với faster-whisper, 14,4 giây lên 33,8 giây với openai-whisper). Đây là đánh đổi cỡ mô hình điển hình.

Con số 2,1 lần nhỏ hơn "tới 4 lần" của README. Không có mâu thuẫn: README đo 13 phút audio trên Intel i7-12700K, 8 luồng, beam 5; bảng của họ ghi openai/whisper fp32 mất 6 phút 58 giây, faster-whisper fp32 mất 2 phút 37 giây, faster-whisper int8 mất 1 phút 42 giây. Tỉ lệ giữa fp32 gốc và int8 là khoảng 4,1 lần trên cấu hình đó. Trên M4, PyTorch đã khá tốt trên CPU Arm nên khoảng cách hẹp lại; nguyên tắc rút ra là phải tự đo trên đúng phần cứng sẽ triển khai.

Sai ở đâu: nhìn từng câu

WER chỉ là một con số; đọc từng lỗi mới biết hệ thống có dùng được không. Với faster-whisper small, câu s4 và s5 cho kết quả:

s4.wav REF: Sản phẩm này được bảo hành mười hai tháng, đổi trả miễn phí trong vòng bảy ngày.
       HYP: Sản phẩm này được bảo hành 12 tháng, đổi trả miễn phí trong vòng bài ngày.
s5.wav REF: Hệ thống sẽ gửi tin nhắn xác nhận đến số điện thoại đã đăng ký của quý khách.
       HYP: Hệ thống sẽ gửi tin nhánh xác nhận đến số điện thoại đã đăng ký của quý khách.

Có ba kiểu lỗi lặp lại. Số viết bằng chữ bị đổi thành chữ số ("mười hai" thành "12", "tám giờ" thành "8h"): đây là khác quy ước viết, làm WER tăng giả tạo, nên chuẩn hoá cả hai phía nếu muốn đo thuần túy. Sai dấu thanh hoặc từ gần âm ("bảy" thành "bài", "tin nhắn" thành "tin nhánh"): lỗi thật với tiếng Việt, nơi dấu thanh quyết định nghĩa; giảm bằng mô hình lớn hơn hoặc từ điển miền. Âm đầu bị nhầm ("Khách" thành "Khác"): câu vẫn trôi chảy nên chỉ lộ khi đối chiếu bản chép tay. "Bài ngày" thay "bảy ngày" làm sai thông tin bảo hành, nên các con số quan trọng cần người xác nhận.

Tối ưu tốc độ Whisper: int8, beam size và VAD

Cả ba tuỳ chọn đều nằm ngay trong lời gọi faster-whisper. Một lần đo đơn lẻ không đủ tin cậy: lần đo đầu tiên của tôi cho kết quả ngược với lần chạy lại, vì cấu hình chạy đầu tiên chịu khởi động nguội và tải của máy dùng chung. Vì vậy script cuối cùng (bench_options.py) nạp sẵn cả hai mô hình, chạy một lượt khởi động bỏ đi, rồi đo mỗi cấu hình 3 lượt với thứ tự xoay vòng để không cấu hình nào luôn chạy trước, và báo trung vị kèm khoảng min–max. Dữ liệu là sáu câu ghép thành 158,3 giây âm thanh (18 câu: ba vòng lặp, giữa các câu xen khoảng lặng 3 giây), model small, condition_on_previous_text=False:

audio dài 158.3s (18 câu, có khoảng lặng 3s)
trung vị của 3 lượt (min-max); thời gian suy luận, RTF = thời gian / độ dài audio
int8     beam=5 vad=False   17.1s (16.1-18.9)  RTF=0.108  WER=9.9%
int8     beam=1 vad=False   10.4s (10.4-10.7)  RTF=0.066  WER=9.6%
int8     beam=1 vad=True    10.3s (9.9-11.4)  RTF=0.065  WER=9.3%
float32  beam=5 vad=False   20.9s (20.1-21.1)  RTF=0.132  WER=9.6%
float32  beam=1 vad=False   10.3s (10.1-10.7)  RTF=0.065  WER=9.9%
float32  beam=1 vad=True    10.1s (9.8-10.7)  RTF=0.064  WER=9.6%

Tiến trình đo này có RSS tối đa khoảng 2,25 GB theo /usr/bin/time -l (giữ đồng thời hai mô hình small, int8 và float32). Vài điều đọc được, kèm cảnh báo về cỡ mẫu (một giọng tổng hợp duy nhất, nên chênh lệch dưới 0,5 điểm WER nằm trong nhiễu của phép đo):

  • Beam size là đòn bẩy rõ nhất và lặp lại được: giảm beam từ 5 xuống 1 làm int8 nhanh hơn từ 17,1 giây xuống 10,4 giây và float32 từ 20,9 giây xuống 10,3 giây, khoảng cách lớn hơn nhiều so với biên độ giữa các lượt đo, trong khi WER không xấu đi trên dữ liệu sạch này. Với giọng sạch, giải mã tham lam đã đủ; với audio nhiễu, beam lớn hơn có thể đáng giá.
  • Lượng tử hoá int8 không tạo khác biệt đáng kể về tốc độ ở beam=1 trên M4: 10,4 giây so với 10,3 giây, trong khi các lượt đo của mỗi cấu hình đã dao động từ 0,3 đến 1,5 giây. Ở beam=5, int8 nhanh hơn float32 (17,1 giây so với 20,9 giây), nhưng chỉ có một cặp số như vậy nên tôi không khái quát hoá. Tài liệu CTranslate2 về quantization mô tả rằng int8 lượng tử hoá trọng số của lớp embedding và lớp tuyến tính, các lớp còn lại chạy ở độ chính xác gốc, và lợi ích chính là bộ nhớ (ví dụ mô hình Transformer base giảm từ 364 MB xuống khoảng 100 MB), còn tốc độ phụ thuộc phần cứng. Kết luận an toàn: chọn int8 để tiết kiệm RAM, đừng kỳ vọng nó tự nhanh hơn, và đo lại trên phần cứng của bạn.
  • VAD (vad_filter=True) không phải công cụ tăng tốc: chênh lệch thời gian (10,4 giây xuống 10,3 giây ở int8, 10,3 giây xuống 10,1 giây ở float32) nằm trong biên độ nhiễu, và lần đo đơn lẻ đầu tiên của tôi từng cho một mức "tiết kiệm" lớn hơn nhiều có lẽ do thứ tự chạy và tải máy. Lý do nên bật VAD là chặn việc mô hình bịa chữ trên khoảng lặng, như thí nghiệm ở phần dưới.

README faster-whisper mô tả vad_filter dùng mô hình Silero VAD để bỏ các đoạn không có tiếng nói và theo mặc định chỉ loại khoảng lặng dài hơn 2 giây; có thể chỉnh bằng vad_parameters, ví dụ min_silence_duration_ms=500. Cấu hình gợi ý:

model = WhisperModel("small", device="cpu", compute_type="int8")
segs, info = model.transcribe(
    "meeting.wav", language="vi", beam_size=1, temperature=0.0,
    vad_filter=True, condition_on_previous_text=False)

Khi Whisper "bịa" chữ: im lặng và nhiễu

Thẻ mô hình openai/whisper-small cảnh báo rằng kết quả có thể chứa văn bản không hề được nói trong audio, và độ chính xác thấp hơn với ngôn ngữ ít dữ liệu, kèm khả năng lặp chữ nhiều hơn. Tôi kiểm tra điều này bằng thí nghiệm, trước hết là 20 giây im lặng tuyệt đối và 20 giây nhiễu trắng (extras.py, model small int8):

[1] Im lặng và nhiễu nền (20 giây mỗi loại), model small int8
  im lặng tuyệt đối          vad=False -> 11 đoạn: Cảm ơn các bạn đã đón tìm thấy video này. | Cảm ơn các bạn đã đón tìm thấy video này. | Cả
  im lặng tuyệt đối          vad=True  -> 0 đoạn: (rỗng)
  nhiễu trắng biên độ 0,01   vad=False -> 0 đoạn: (rỗng)
  nhiễu trắng biên độ 0,01   vad=True  -> 0 đoạn: (rỗng)

Hai mươi giây im lặng tuyệt đối sinh ra 11 đoạn lặp lại câu "Cảm ơn các bạn đã đón tìm thấy video này", đúng kiểu câu kết video mà dữ liệu huấn luyện từ internet chứa rất nhiều. Bật vad_filter=True thì không còn đoạn nào. Nhiễu trắng biên độ nhỏ không gây ra chữ, nhưng nhạc nền hay tiếng người nói xa là các trường hợp chưa đo. Khuyến nghị thực tế: luôn bật VAD cho ghi âm có khoảng lặng dài, và lọc kết quả có no_speech_prob cao hoặc lặp một cụm nhiều lần.

Thí nghiệm thứ hai cộng nhiễu trắng vào từng câu theo tỉ lệ tín hiệu trên nhiễu (SNR) giảm dần, so base với small, beam=1:

  SNR           base     small
  sạch         20.9%     10.4%
  20 dB        23.5%     13.0%
  10 dB        30.4%     23.5%
  5 dB         47.0%    106.1%
  0 dB         67.0%     50.4%

Hai nhận xét đầu: WER tăng đều khi nhiễu tăng, và small tốt hơn base ở mức sạch tới 10 dB. Con số 106,1% của small ở 5 dB lớn hơn 100% được vì WER tính cả lỗi chèn từ thừa. Để kiểm chứng nguyên nhân thay vì đoán, tôi in thêm số từ mô hình sinh ra ở 5 dB và bản chép thô của câu có số từ vượt trội nhất:

[2b] Bản chép thô ở 5 dB: số từ đáp án so với số từ mô hình sinh ra (từng câu)
  base   số từ đáp án=115 | số từ sinh ra=114 | theo câu: [19, 19, 19, 16, 18, 23]
  small  số từ đáp án=115 | số từ sinh ra=183 | theo câu: [19, 90, 19, 16, 17, 22]
  small, câu s2 ở 5 dB, 90 từ (đáp án 19 từ):
    họ thông thủ hành hệ thủy hệ thủy hệ thủy hệ thủy hệ thủy hệ thủy hệ thủy hệ thủy hệ thủy ... (rút gọn, output thật dài hơn)

Chỉ một câu (s2) làm small rơi vào vòng lặp "hệ thủy" và sinh 90 từ cho đáp án 19 từ; năm câu còn lại có số từ gần đáp án. Đây đúng là hiện tượng lặp chữ mà thẻ mô hình cảnh báo, và nó giải thích vì sao WER vượt 100% mà không cần mô hình nghe sai nhiều câu.

Nhận xét thứ ba: ở 0 dB, small lại tốt hơn 5 dB (50,4% so với 106,1%). Điều này nhắc rằng với sáu câu và một hạt giống nhiễu, kết quả bị một vài câu hỏng chi phối và không nên đọc thành đường cong trơn. Thông điệp vận hành: dưới 10 dB SNR thì Whisper cỡ nhỏ trên CPU không còn đáng tin, cần lọc nhiễu đầu vào, chặn kết quả lặp hoặc dùng mô hình lớn hơn.

Lưu ý: base sạch ở đây có WER 20,9% còn thí nghiệm đầu là 14,8%, vì ở đây beam=1 và condition_on_previous_text=False. Luôn ghi cấu hình cạnh mỗi con số.

Mốc thời gian cho phụ đề

faster-whisper trả mốc theo đoạn, và theo từng từ khi bật word_timestamps=True (mỗi từ có start, end, word). Kết quả cho câu đầu tiên:

  đoạn [ 0.00 ->  5.46] Xin chào quý khách, đơn hàng của anh đã được xác nhận và sẽ giao trong 2 ngày tới.
  từ: Xin@0.0 chào@0.2 quý@0.6 khách,@0.9 đơn@1.4 hàng@1.6 của@1.9 anh@2.2 đã@2.4 được@2.7 xác@2.9 nhận@3.3 và@3.5 sẽ@3.7 giao@4.0 trong@4.3 2@4.6 ngày@4.9 tới.@5.1

Mốc từ là ước lượng căn chỉnh sau khi giải mã, nên sai lệch vài phần mười giây là bình thường.

Tài nguyên khi chạy nhiều file

Tiến trình thí nghiệm về nhiễu (nạp cả base lẫn small int8) có RSS tối đa khoảng 1,46 GB theo /usr/bin/time -l; tiến trình đo cấu hình (hai mô hình small) khoảng 2,25 GB, vẫn dưới ngưỡng 3 GB. Giải mã nặng CPU nên đặt cố định cpu_threads và đẩy vào hàng đợi thay vì chạy trong request web; khi chạy trong container hay Kubernetes, hãy đặt giới hạn CPU và bộ nhớ cho dịch vụ nặng CPU kiểu này, cách tính ở Requests và limits, QoS và nodeAffinity: xếp pod lên đúng node.

Khi nào không nên dùng Whisper local

  • Cần độ chính xác cao với tiếng Việt miền, giọng vùng, nhiều người nói chồng lấn: tự đánh giá trên dữ liệu thật; có thể cần mô hình lớn hơn hoặc dịch vụ chuyên biệt.
  • Cần nhận dạng thời gian thực với độ trễ dưới một giây: Whisper xử lý theo cửa sổ 30 giây, không thiết kế cho streaming; có kỹ thuật chia nhỏ nhưng phức tạp hơn.
  • Cần tách người nói (diarization): Whisper không làm việc này; phải ghép thêm công cụ khác.

Lưu ý pháp lý: ghi âm cuộc gọi chứa dữ liệu cá nhân, cần thông báo và có căn cứ hợp pháp. Chạy local giữ dữ liệu trong hạ tầng nhưng không thay cho nghĩa vụ này.

Lỗi thường gặp

  1. Đo thời gian mà quên duyệt generator của faster-whisper: thời gian gần bằng 0 vì chưa giải mã gì.
  2. So WER mà không chuẩn hoá: dấu câu, chữ hoa, chữ số và số viết bằng chữ làm WER lệch hàng chục phần trăm.
  3. Không dùng VAD với audio có khoảng lặng dài: sinh ra các câu "bịa" lặp lại (VAD để chặn lỗi này, không phải để tăng tốc).
  4. Giả định int8 luôn nhanh hơn, hoặc kết luận từ một lần đo: trên M4 với beam=1, đo 3 lượt cho thấy int8 và float32 không khác nhau đáng kể về tốc độ; lợi thế chắc chắn của int8 là bộ nhớ. Luôn có lượt khởi động và lặp đo nhiều lần.

Checklist đưa Whisper vào sản phẩm

  • Chép tay vài chục phút ghi âm thật (đã được phép) làm đáp án và đo WER sau khi chuẩn hoá.
  • Chọn cỡ mô hình theo WER và RTF của bạn; ghi cả beam size, compute_type, VAD.
  • Bật vad_filter, đặt language; chặn kết quả lặp.
  • Giới hạn RAM, luồng CPU và có chính sách lưu ghi âm rõ ràng.

Mã nằm trong code/s7-29 cùng các file *_output.txt và requirements.txt; mô hình tự tải từ Hugging Face lần đầu. Đoạn dùng giọng say -v Linh chỉ chạy được trên macOS.

Bài tiếp theo

OCR tài liệu tiếng Việt: Tesseract, PaddleOCR và hậu xử lý. Sau khi biến giọng nói thành văn bản, bài sau xử lý tài liệu dạng ảnh: nhận dạng chữ trên hoá đơn, giấy tờ bằng Tesseract và PaddleOCR, rồi hậu xử lý để sửa lỗi dấu tiếng Việt.