virtual-class / README.md
tourmii's picture
gpu
cb0b5bc
|
Raw
History Blame Contribute Delete
33.3 kB
metadata
title: Lớp học ảo  Tứ giác
emoji: 📐
colorFrom: blue
colorTo: indigo
sdk: docker
app_port: 7860
suggested_hardware: t4-small
pinned: false
short_description: Thầy giáo chibi giảng bài Tứ giác bằng giọng nói

Lớp học ảo — Bài 10. Tứ giác

Một ứng dụng cho một bài giảng. Mở lên là thấy bảng chiếm trọn màn hình, phần hỏi đáp nằm dưới. Học sinh bấm một chủ đề hoặc gõ câu hỏi, thầy giáo chibi viết công thức và vẽ hình lên bảng, đọc thành tiếng, miệng cử động khớp lời.

Không có bước tải tài liệu. Nội dung bài nằm sẵn trong content/bai-10-tu-giac/ và được nạp lúc khởi động — nhanh hơn, và quan trọng hơn là kiểm soát được đúng những gì học sinh sẽ nghe.

Điểm mấu chốt của thiết kế

Model không trả về văn xuôi rồi mình bóc tách tùy hứng. Nó bị ép về một schema chặt chẽ, trả về hai kênh tách biệt cho mỗi bước:

{
  "title": "Định lý Pytago",
  "steps": [
    {
      "say": "Bình phương cạnh huyền bằng tổng bình phương hai cạnh góc vuông.",
      "board": { "kind": "latex", "content": "a^2 + b^2 = c^2" }
    }
  ]
}

say là lời nói — tiếng Việt thuần, không có ký hiệu, vì nó đi thẳng vào bộ đọc. board là thứ viết lên bảng — LaTeX, SVG hoặc một dòng chữ. Nếu trộn chung hai kênh, bộ đọc sẽ phát ra "backslash sqrt" và học sinh nghe không hiểu gì.

Giáo án soạn sẵn, model chỉ là đường lui

Phần lớn câu hỏi trong một tiết học là đoán trước được: tóm tắt khái niệm, xin hình minh họa, chữa bài trên lớp, hỏi bài về nhà. Những câu đó không nên phải chờ model — và quan trọng hơn, không nên có nguy cơ model tính sai một con số rồi cả lớp chép theo.

Nên luồng là: khớp scenario trước, gọi model sau.

câu hỏi ─→ khớp với scenario của pack ─→ trúng?  ─→ giáo án soạn sẵn (tức thì)
                                          └─ trượt ─→ model sinh mới

content/bai-10-tu-giac/ là pack duy nhất được cài: 31 scenario, 242 bước giảng viết tay với công thức LaTeX, 24 hình SVG, cộng context.md là toàn văn nội dung slide dùng làm ngữ cảnh cho model khi câu hỏi rơi ra ngoài. Sáu nhóm hiện thành sáu tab dưới bảng:

Nhóm Nguồn Số bài
Tóm tắt khái niệm Slide bài giảng 6
Vẽ hình minh họa Slide bài giảng 3
Bài tập trên lớp SGK, bài 3.1–3.3 7
Bài tập về nhà Slide bài giảng 2
Phiếu: ví dụ mẫu Phiếu bài tập, ví dụ 1–5 5
Phiếu: bài tập Phiếu bài tập, bài 1–8 8

Giáo án được viết theo lối dẫn dắt chứ không đọc đáp án: nêu dữ kiện, chỉ ra chỗ bẫy, giải từng bước, rồi kiểm tra lại kết quả bằng mắt. Bài 3.1 dừng hẳn một bước để nói rõ vì sao 60° và 110° là góc ngoài chứ không phải góc trong — đó chính là chỗ học sinh mất điểm.

Sửa lời giảng thì dùng scripts/write_lessons.py: nó chỉ thay phần lesson và giữ nguyên triggers, keywords, blockers, requires — những thứ đã được tinh chỉnh theo bộ 24 câu kiểm tra và không nên bị đụng vào khi chỉ muốn sửa câu chữ.

Bộ khớp câu hỏi

Cố ý không dùng embedding. Một pack chỉ phủ một bài nên tập ứng viên chỉ vài chục scenario và từ vựng biết trước — giáo viên đọc được triggers, đoán được câu nào sẽ trúng, và sửa một câu trượt bằng cách thêm một dòng. Embedding sẽ biến việc bảo trì nội dung thành hộp đen, lại tốn một vòng GPU cho mỗi câu hỏi.

Tiếng Việt có ba chỗ phải cẩn thận:

  • N-gram tới ba. Gần như mọi thuật ngữ ở đây là hai hoặc ba âm tiết: "đường chéo", "góc đối", "tứ giác lồi", "tổng các góc". Khớp theo từng âm tiết thì "góc" và "đối" bắn vào mọi thứ. Ba cũng là trần cứng cho tác giả pack, kiểm tra ngay lúc nạp: từ khoá dài hơn không bao giờ nằm trong tập gram nên nó ngồi đó trông có vẻ có tác dụng mà đóng góp đúng số không — loại cấu hình hỏng tệ nhất, vì scenario vẫn nửa chạy được nhờ triggers và không ai nhận ra.
  • Dấu, đánh chỉ mục cả hai chiều. Học sinh gõ không dấu nên dạng bỏ dấu phải khớp được — nhưng chỉ bỏ dấu thì nguy hiểm: "vẽ" và "về" đều thành "ve", và "học về hình bình hành" sẽ trông y hệt một yêu cầu vẽ hình. Câu hỏi vì vậy sinh gram ở cả hai dạng, còn tác giả pack chọn: từ khoá viết "vẽ" chỉ bắn khi có dấu, viết "ve" thì bắn cả hai.
  • requiresblockers. requires bắt buộc một cụm phải xuất hiện — scenario vẽ hình đòi có "tứ giác" nên câu hỏi về hình bình hành rơi xuống model thay vì nhận nhầm hình. blockers loại theo chiều ngược lại: không có nó thì "bài 3.2" ăn điểm cao ở giáo án bài 3.1, vì hai câu giống nhau mọi chữ trừ con số.

Bộ test giữ 41 câu mẫu, gồm bốn câu bắt buộc phải trượt xuống model. Trong đó có mấy cặp cố tình đặt sát nhau để bắt lỗi lẫn nội dung: "bài 1" của phiếu và "bài 3.1" của SGK là hai bài khác hẳn; "trung trực của AC" là phiếu bài 1, còn "trung trực của BD" là SGK bài 3.3 — hai mệnh đề ngược nhau.

Sửa và thêm nội dung

pack.json giữ lời giảng và từ khoá; hình nằm riêng trong figures/*.svg nên mở bằng editor nào cũng được và không phải escape dấu nháy vào JSON. Pack được nạp, resolve hình và validate một lần lúc khởi động — sai chính tả, thiếu file hình hay lỡ nhét LaTeX vào lời nói là server không lên, kèm tên scenario gây lỗi. Thà vậy còn hơn phát hiện lúc cả lớp đang nhìn.

Gen sẵn giọng nói

Mọi câu thầy sẽ nói đều biết trước, nên không có lý do gì bắt học sinh chờ:

python scripts/prebuild_audio.py --pack bai-10-tu-giac
python scripts/prebuild_audio.py --speeds 0.9 1.0 1.1   # dựng sẵn cả các mức tốc độ

Audio nằm ở content/.audio-cache/, khoá băm theo provider, giọng, tốc độ và định dạng — đổi VIENEU_VOICE là trượt cache chứ không phát nhầm clip cũ. Lớp cache trên đĩa này nằm trên LRU trong RAM của vieneu_tts.py và sống qua mọi lần khởi động lại.

Ép schema trên endpoint OpenAI-compatible

Backend nói giao thức OpenAI nên cắm được vào NVIDIA NIM, vLLM, Together hay OpenAI. Cái giá phải trả: không endpoint nào cam kết hỗ trợ tool_choice bắt buộc hay response_format: json_schema, và không có cách nào biết ngoài việc thử. Nên llm.py tụt hạng dần:

tool_call  ->  json_schema  ->  prompt + tự bóc JSON

Nấc nào chạy được thì được nhớ cho cả vòng đời process, chỉ dò một lần duy nhất. Muốn chốt cứng thì đặt LLM_STRUCTURED_MODE=prompt chẳng hạn.

Model reasoning như GLM còn thêm một chỗ vướng: phần suy nghĩ nằm ở reasoning_content, và một số bản inline luôn <think>...</think> vào content. extract_json() gỡ cả hai, bỏ code fence, bỏ lời dẫn thừa, rồi đếm ngoặc có nhận biết chuỗi — vì dấu { nằm trong nhãn SVG sẽ làm hỏng phép đếm ngây thơ.

Cấu trúc

backend/app/
  main.py               FastAPI, rate limit, phục vụ luôn frontend tĩnh
  config.py             Đọc cấu hình từ biến môi trường
  schemas.py            Pydantic — hợp đồng dữ liệu giữa model, API và trình duyệt
  routers/
    documents.py        POST /api/documents — upload và trích xuất
    lessons.py          POST /api/lessons, POST /api/speech
  services/
    extract.py          PDF qua pypdf, PPTX qua python-pptx (kèm ghi chú slide)
    llm.py              Gọi model qua giao thức OpenAI, ép schema, hậu xử lý
    packs.py            Nạp và validate giáo án soạn sẵn lúc khởi động
    matcher.py          Khớp câu hỏi tiếng Việt với scenario
    audio_cache.py      Cache giọng nói trên đĩa, sống qua restart
    sanitize.py         Lọc SVG theo allowlist trước khi cho vào DOM
    speech.py           Chọn provider đọc: browser, vieneu hay Google Cloud
    vieneu_tts.py       VieNeu-TTS trên CPU: cắt câu, dựng mốc, nén audio
    store.py            Kho tài liệu tạm trong RAM, có TTL

frontend/
  index.html            Khung trang, nhân vật chibi dựng bằng SVG nội tuyến
  css/style.css         Bảng xanh, phấn trắng, vở ô ly, mực tím
  js/api.js             Bọc các lời gọi backend
  js/board.js           Render KaTeX và SVG, hiệu ứng nét phấn hiện dần
  js/speech.js          Đọc và điều khiển khớp miệng
  js/teacher.js         Bảng viseme cho nhân vật
  js/app.js             Điều phối toàn bộ
  js/sample.js          Bài mẫu offline khi backend chưa sẵn sàng

scripts/
  smoke_llm.py          Thử endpoint bằng stream, hoặc sinh thử một giáo án
  smoke_tts.py          Đọc thử một câu, in RTF và mốc thời gian từng từ

Chạy thử

cp .env.example .env        # điền LLM_API_KEY
make install                # tạo .venv, ~900 MB vì kèm VieNeu-TTS
make test                   # 69 test, dưới 2 giây
make dev                    # mở http://localhost:8000

.env được config.py tự nạp — không cần source hay --env-file, và cũng đừng source .env bằng shell: VIENEU_VOICE=Trúc Ly có dấu cách nên sh báo lỗi. Biến môi trường thật (docker-compose, CI) luôn thắng .env.

Muốn chạy nhẹ trước khi đụng tới giọng nói thì để SPEECH_PROVIDER=browser; /api/speech sẽ trả 501 và trình duyệt tự đọc bằng Web Speech API. Riêng bản cài đặt vẫn kéo VieNeu về vì nó nằm trong requirements.txt — bỏ dòng đó nếu chỉ định dùng giọng trình duyệt, .venv khi ấy chỉ còn khoảng 90 MB.

Ba lệnh kiểm tra khi cần: make smoke gọi thử endpoint model, make smoke-lesson sinh thử một giáo án và in chế độ structured output đã chốt, make smoke-tts đọc thử một câu và in RTF cùng mốc thời gian từng từ.

Hoặc bằng Docker:

cp .env.example .env
docker compose up --build       # lần đầu chưa có giọng dựng sẵn
make docker-prebuild            # dựng 132 câu vào volume audio-cache

Cache giọng nói và image

content/.audio-cache/ không đi cùng image. Nó bị .dockerignore loại ra, vì nội dung của nó phụ thuộc việc ai đã chạy make prebuild trên máy nào — để lọt vào thì hai người build cùng một commit sẽ ra hai image khác nhau.

Cache sống trong volume audio-cache gắn vào /srv/content/.audio-cache, nên nó qua được mọi lần docker compose up --build. Dựng lại sau khi sửa pack:

make docker-prebuild

Nếu triển khai lên nơi không gắn được volume — Cloud Run, hay bất cứ runtime nào filesystem chỉ đọc — thì nướng thẳng giọng vào image:

make docker-selfcontained       # = docker compose build --build-arg PREBUILD_AUDIO=1

Bước này tải model ngay lúc build nên lâu và làm image phồng lên, đổi lại container chạy được ở chế độ chỉ đọc. Giọng lúc build phải trùng giọng lúc chạy, nếu không khoá cache sẽ trượt hết và công dựng đổ sông.

Hai chi tiết quyền hạn dễ mất cả buổi để tìm ra:

  • /srv/content được COPY vào dưới quyền root, còn container chạy bằng user lophoc. Không chown thì audio_cache.put() gặp OSError, mà hàm đó bắt lỗi rồi chỉ log một dòng cảnh báo — nhìn bên ngoài app vẫn chạy đúng, chỉ là mỗi câu bị tổng hợp lại từ đầu mãi mãi.
  • Volume rỗng lúc tạo mới kế thừa quyền sở hữu từ image. Volume cũ do root sở hữu thì docker compose down -v rồi dựng lại.

Giáo án soạn sẵn, model chỉ là đường lui

Phần lớn câu hỏi trong một tiết học là đoán trước được: tóm tắt khái niệm, xin hình minh họa, chữa bài trên lớp, hỏi bài về nhà. Những câu đó không nên phải chờ model — và quan trọng hơn, không nên có nguy cơ model tính sai một con số rồi cả lớp chép theo.

Nên luồng là: khớp scenario trước, gọi model sau.

câu hỏi ─→ khớp với scenario của pack ─→ trúng?  ─→ giáo án soạn sẵn (tức thì)
                                          └─ trượt ─→ model sinh mới

content/bai-10-tu-giac/ là pack duy nhất được cài: 31 scenario, 242 bước giảng viết tay với công thức LaTeX, 24 hình SVG, cộng context.md là toàn văn nội dung slide dùng làm ngữ cảnh cho model khi câu hỏi rơi ra ngoài. Sáu nhóm hiện thành sáu tab dưới bảng:

Nhóm Nguồn Số bài
Tóm tắt khái niệm Slide bài giảng 6
Vẽ hình minh họa Slide bài giảng 3
Bài tập trên lớp SGK, bài 3.1–3.3 7
Bài tập về nhà Slide bài giảng 2
Phiếu: ví dụ mẫu Phiếu bài tập, ví dụ 1–5 5
Phiếu: bài tập Phiếu bài tập, bài 1–8 8

Giáo án được viết theo lối dẫn dắt chứ không đọc đáp án: nêu dữ kiện, chỉ ra chỗ bẫy, giải từng bước, rồi kiểm tra lại kết quả bằng mắt. Bài 3.1 dừng hẳn một bước để nói rõ vì sao 60° và 110° là góc ngoài chứ không phải góc trong — đó chính là chỗ học sinh mất điểm.

Sửa lời giảng thì dùng scripts/write_lessons.py: nó chỉ thay phần lesson và giữ nguyên triggers, keywords, blockers, requires — những thứ đã được tinh chỉnh theo bộ 24 câu kiểm tra và không nên bị đụng vào khi chỉ muốn sửa câu chữ.

Bộ khớp câu hỏi

Cố ý không dùng embedding. Một pack chỉ phủ một bài nên tập ứng viên chỉ vài chục scenario và từ vựng biết trước — giáo viên đọc được triggers, đoán được câu nào sẽ trúng, và sửa một câu trượt bằng cách thêm một dòng. Embedding sẽ biến việc bảo trì nội dung thành hộp đen, lại tốn một vòng GPU cho mỗi câu hỏi.

Tiếng Việt có ba chỗ phải cẩn thận:

  • N-gram tới ba. Gần như mọi thuật ngữ ở đây là hai hoặc ba âm tiết: "đường chéo", "góc đối", "tứ giác lồi", "tổng các góc". Khớp theo từng âm tiết thì "góc" và "đối" bắn vào mọi thứ. Ba cũng là trần cứng cho tác giả pack, kiểm tra ngay lúc nạp: từ khoá dài hơn không bao giờ nằm trong tập gram nên nó ngồi đó trông có vẻ có tác dụng mà đóng góp đúng số không — loại cấu hình hỏng tệ nhất, vì scenario vẫn nửa chạy được nhờ triggers và không ai nhận ra.
  • Dấu, đánh chỉ mục cả hai chiều. Học sinh gõ không dấu nên dạng bỏ dấu phải khớp được — nhưng chỉ bỏ dấu thì nguy hiểm: "vẽ" và "về" đều thành "ve", và "học về hình bình hành" sẽ trông y hệt một yêu cầu vẽ hình. Câu hỏi vì vậy sinh gram ở cả hai dạng, còn tác giả pack chọn: từ khoá viết "vẽ" chỉ bắn khi có dấu, viết "ve" thì bắn cả hai.
  • requiresblockers. requires bắt buộc một cụm phải xuất hiện — scenario vẽ hình đòi có "tứ giác" nên câu hỏi về hình bình hành rơi xuống model thay vì nhận nhầm hình. blockers loại theo chiều ngược lại: không có nó thì "bài 3.2" ăn điểm cao ở giáo án bài 3.1, vì hai câu giống nhau mọi chữ trừ con số.

Bộ test giữ 41 câu mẫu, gồm bốn câu bắt buộc phải trượt xuống model. Trong đó có mấy cặp cố tình đặt sát nhau để bắt lỗi lẫn nội dung: "bài 1" của phiếu và "bài 3.1" của SGK là hai bài khác hẳn; "trung trực của AC" là phiếu bài 1, còn "trung trực của BD" là SGK bài 3.3 — hai mệnh đề ngược nhau.

Sửa và thêm nội dung

pack.json giữ lời giảng và từ khoá; hình nằm riêng trong figures/*.svg nên mở bằng editor nào cũng được và không phải escape dấu nháy vào JSON. Pack được nạp, resolve hình và validate một lần lúc khởi động — sai chính tả, thiếu file hình hay lỡ nhét LaTeX vào lời nói là server không lên, kèm tên scenario gây lỗi. Thà vậy còn hơn phát hiện lúc cả lớp đang nhìn.

Gen sẵn giọng nói

Mọi câu thầy sẽ nói đều biết trước, nên không có lý do gì bắt học sinh chờ:

python scripts/prebuild_audio.py --pack bai-10-tu-giac
python scripts/prebuild_audio.py --speeds 0.9 1.0 1.1   # dựng sẵn cả các mức tốc độ

Audio nằm ở content/.audio-cache/, khoá băm theo provider, giọng, tốc độ và định dạng — đổi VIENEU_VOICE là trượt cache chứ không phát nhầm clip cũ. Lớp cache trên đĩa này nằm trên LRU trong RAM của vieneu_tts.py và sống qua mọi lần khởi động lại.

Ép schema trên endpoint OpenAI-compatible

Backend nói giao thức OpenAI nên cắm được vào NVIDIA NIM, vLLM, Together hay OpenAI. Cái giá phải trả: không endpoint nào cam kết hỗ trợ tool_choice bắt buộc hay response_format: json_schema, và không có cách nào biết ngoài việc thử. Nên llm.py tụt hạng dần:

tool_call  ->  json_schema  ->  prompt + tự bóc JSON

Nấc nào chạy được thì được nhớ cho cả vòng đời process, chỉ dò một lần duy nhất. Muốn chốt cứng thì đặt LLM_STRUCTURED_MODE=prompt chẳng hạn.

Model reasoning như GLM còn thêm một chỗ vướng: phần suy nghĩ nằm ở reasoning_content, và một số bản inline luôn <think>...</think> vào content. extract_json() gỡ cả hai, bỏ code fence, bỏ lời dẫn thừa, rồi đếm ngoặc có nhận biết chuỗi — vì dấu { nằm trong nhãn SVG sẽ làm hỏng phép đếm ngây thơ.

Cấu trúc

backend/app/
  main.py               FastAPI, rate limit, phục vụ luôn frontend tĩnh
  config.py             Đọc cấu hình từ biến môi trường
  schemas.py            Pydantic — hợp đồng dữ liệu giữa model, API và trình duyệt
  routers/
    documents.py        POST /api/documents — upload và trích xuất
    lessons.py          POST /api/lessons, POST /api/speech
  services/
    extract.py          PDF qua pypdf, PPTX qua python-pptx (kèm ghi chú slide)
    llm.py              Gọi model qua giao thức OpenAI, ép schema, hậu xử lý
    packs.py            Nạp và validate giáo án soạn sẵn lúc khởi động
    matcher.py          Khớp câu hỏi tiếng Việt với scenario
    audio_cache.py      Cache giọng nói trên đĩa, sống qua restart
    sanitize.py         Lọc SVG theo allowlist trước khi cho vào DOM
    speech.py           Chọn provider đọc: browser, vieneu hay Google Cloud
    vieneu_tts.py       VieNeu-TTS trên CPU: cắt câu, dựng mốc, nén audio
    store.py            Kho tài liệu tạm trong RAM, có TTL

frontend/
  index.html            Khung trang, nhân vật chibi dựng bằng SVG nội tuyến
  css/style.css         Bảng xanh, phấn trắng, vở ô ly, mực tím
  js/api.js             Bọc các lời gọi backend
  js/board.js           Render KaTeX và SVG, hiệu ứng nét phấn hiện dần
  js/speech.js          Đọc và điều khiển khớp miệng
  js/teacher.js         Bảng viseme cho nhân vật
  js/app.js             Điều phối toàn bộ
  js/sample.js          Bài mẫu offline khi backend chưa sẵn sàng

scripts/
  smoke_llm.py          Thử endpoint bằng stream, hoặc sinh thử một giáo án
  smoke_tts.py          Đọc thử một câu, in RTF và mốc thời gian từng từ

Chạy thử

cp .env.example .env        # điền LLM_API_KEY
make install                # tạo .venv, ~900 MB vì kèm VieNeu-TTS
make test                   # 69 test, dưới 2 giây
make dev                    # mở http://localhost:8000

.env được config.py tự nạp — không cần source hay --env-file, và cũng đừng source .env bằng shell: VIENEU_VOICE=Trúc Ly có dấu cách nên sh báo lỗi. Biến môi trường thật (docker-compose, CI) luôn thắng .env.

Muốn chạy nhẹ trước khi đụng tới giọng nói thì để SPEECH_PROVIDER=browser; /api/speech sẽ trả 501 và trình duyệt tự đọc bằng Web Speech API. Riêng bản cài đặt vẫn kéo VieNeu về vì nó nằm trong requirements.txt — bỏ dòng đó nếu chỉ định dùng giọng trình duyệt, .venv khi ấy chỉ còn khoảng 90 MB.

Ba lệnh kiểm tra khi cần: make smoke gọi thử endpoint model, make smoke-lesson sinh thử một giáo án và in chế độ structured output đã chốt, make smoke-tts đọc thử một câu và in RTF cùng mốc thời gian từng từ.

Hoặc bằng Docker:

cp .env.example .env
docker compose up --build

Model VieNeu nằm trong volume hf-cache, mount vào ~/.cache/huggingface của user lophoc trong container. Volume phải rỗng lúc tạo thì mới kế thừa quyền sở hữu từ image; nếu trước đó nó đã được Docker tạo dưới quyền root thì log sẽ là PermissionError: '/home/lophoc/.cache/huggingface/hub', xoá đi rồi dựng lại:

docker compose down && docker volume rm lop-hoc-ao_hf-cache && docker compose up --build

Chạy test:

make test

API

Method Đường dẫn Việc
POST /api/lessons Khớp scenario, trượt thì mới gọi model
GET /api/packs Danh sách pack giáo án soạn sẵn
GET /api/packs/{id} Chip gợi ý, nhóm theo bốn loại câu hỏi
POST /api/speech Tổng hợp giọng nói kèm timing từng từ
GET /api/health Kiểm tra cấu hình

Tài liệu tương tác đầy đủ tại http://localhost:8000/docs.

Giọng tiếng Việt chạy trên CPU

Web Speech đọc tiếng Việt như loa phát thanh nhà ga, còn Google Cloud thì tính tiền theo ký tự và gửi từng câu bài giảng của học sinh ra ngoài. Nên mặc định bây giờ là VieNeu-TTS (Apache 2.0): v3-Turbo chạy qua ONNX Runtime, không cần torch, không cần GPU, không cần key.

make install                # ~2 GB thư viện
make smoke-tts              # lần đầu tải thêm ~1 GB model về ~/.cache/huggingface

SPEECH_PROVIDER=vieneu là xong. Vieneu() được dựng trần, không truyền tham số — mặc định của nó đã tự chọn ONNX khi máy không có GPU, và đó là đường mà upstream test kỹ nhất. Chọn giọng bằng VIENEU_VOICE, có 14 giọng preset nam nữ ba miền.

Đo trên máy 12 nhân: nạp model 7 giây, rồi RTF ≈ 0.94 — tổng hợp một câu mất gần đúng bằng thời lượng câu đó. Nghĩa là nếu đợi tổng hợp xong mới đọc thì cứ mỗi bước học sinh nhìn cái bảng câm chừng năm giây. Hai chỗ chữa:

  • VIENEU_PRELOAD=1 nạp model ngay lúc khởi động, trong một thread riêng để healthcheck của container không chết oan trong lúc chờ.
  • app.js đặt hàng trước câu của bước kế ngay khi bước này bắt đầu đọc, nên phần tổng hợp chạy song song với phần phát. Chỉ câu đầu tiên là phải đợi.

Server còn cache theo (câu, giọng, tốc độ): tua lại một bước không tốn thêm giây CPU nào.

ffmpeg trong PATH thì audio trả về là MP3 — nhỏ hơn WAV 48 kHz chừng 50 lần, và đổi tốc độ đọc bằng atempo không làm giọng méo lên như sóc. Không có thì rơi về WAV thô, vẫn chạy.

Khớp miệng hoạt động thế nào

Có ba đường, frontend tự chọn:

  1. VieNeu-TTS (SPEECH_PROVIDER=vieneu, mặc định) — engine chỉ trả sóng âm, không có mốc từng từ. Nên câu được cắt thành từng đoạn ngắn, mỗi đoạn tổng hợp riêng, và thời lượng đo được của đoạn thành một mốc cứng. Trong một đoạn thì chia thời gian theo trọng số: tiếng Việt đều nhịp nên mỗi tiếng một phách, cộng thêm cho token dài (số, từ tiếng Anh) và cho khoảng nghỉ mà dấu phẩy mua được. Vài giây một mốc thật thì sai số không kịp lộ ra; đoán suốt cả đoạn văn thì lộ ngay.
  2. Google Cloud (SPEECH_PROVIDER=google) — mỗi từ được bọc trong một SSML mark, API trả về mốc thời gian chính xác. Miệng chạy theo timestamp thật.
  3. Trình duyệt tự đọc (SPEECH_PROVIDER=browser) — Web Speech API không cho biết đang đọc tới đâu một cách đáng tin: Chrome bắn boundary theo từ, Safari gần như không bắn. Nên speech.js chạy một bộ đếm ký tự theo tốc độ ước lượng và chỉ dùng boundary để chỉnh lại con trỏ khi có.

Ký tự được đưa qua normalize("NFD") để bỏ dấu, rồi map về viseme: a/ă mở to, i/y dẹt, o/ô/ơ tròn, m/b/p khép môi. Tiếng Việt nhiều nguyên âm rõ nên cách này nhìn khá thuyết phục dù rất rẻ.

Bảng không xoá giữa chừng

Bản đầu mỗi bước xoá sạch bảng rồi viết lại. Nhìn thì gọn, nhưng học sinh ngước lên đúng bước cuối chỉ thấy một đáp số trơ trọi, không còn hình và không còn dòng nào để bám vào. Giáo viên thật không dạy như vậy.

Bảng là hình chữ nhật rộng nên chia đôi theo chiều ngang:

┌──────────────────────────────────────────────────────┐
│ [thầy]   ┌─── HÌNH (ghim) ───┐   ┌─ LẬP LUẬN ──────┐  │
│          │                   │   │ dòng 1  (mờ)    │  │
│          │   to hết cỡ       │   │ dòng 2  (mờ)    │  │
│          │                   │   │ dòng 3  ● hiện  │  │
│          └───────────────────┘   └─────────────────┘  │
└──────────────────────────────────────────────────────┘

Hình được ghim bên trái và ưu tiên chỗ (flex: 1.2), đứng nguyên suốt bài chứ không vẽ lại mỗi bước — nét phấn chỉ chạy lại khi hình thực sự đổi. Lập luận cộng dồn thành cột bên phải: dòng đang nói to và rõ, dòng cũ nhỏ lại còn khoảng 70% và mờ xuống 0.5, nhưng vẫn đọc được. Cột dài quá thì tự cuộn tới dòng mới nhất.

Bài không có hình nào thì cột lập luận trải giữa bảng và chữ to hẳn lên.

Ngữ pháp nội dung tương ứng, trong pack.json:

Trường Tác dụng
board.kind: "figure" Treo vào khung hình bên trái, thay hình cũ
board.kind: "latex" | "text" Thêm một dòng vào cột lập luận
bỏ hẳn board Thầy chỉ nói, bảng giữ nguyên
board.highlight Đóng khung dòng đó — dùng cho kết luận
section Kẻ một vạch đứt kèm nhãn, cho bài có câu a, câu b

Trạng thái bảng được dựng lại từ steps[0..i] mỗi lần đổi bước, thay vì cộng dồn tăng dần. Tốn thêm ít node DOM, đổi lại việc nhảy lùi luôn đúng — không có cơ chế undo nào để làm sai.

Bố cục màn hình

.app là một grid hai hàng cao đúng 100dvh: bảng lấy 1fr, khối hỏi đáp lấy phần còn lại và tự cuộn bên trong. Cả trang không cuộn, nên bảng không bao giờ bị đẩy khuất khi log hỏi đáp dài ra — chiếu lên máy chiếu là đúng cái cần nhìn luôn nằm đó.

Chiều cao nhân vật và cỡ chữ trên bảng đều theo đơn vị tương đối (%, vw) nên bảng to lên thì thầy và công thức to theo. Dưới 820px bố cục xếp dọc, trang cuộn lại như bình thường.

Phím tắt khi không ở trong ô nhập: Space giảng tiếp hoặc tạm dừng, chuyển bước. Trong ô câu hỏi thì Enter gửi, Shift+Enter xuống dòng.

Bảo mật

  • API key chỉ nằm ở server, trình duyệt không bao giờ thấy. Đừng nhúng key vào file JS như trong ví dụ khởi động của NVIDIA — bất kỳ ai mở DevTools cũng lấy được.
  • SVG do model sinh ra bị lọc hai lần: allowlist thẻ và thuộc tính ở server (sanitize.py), thêm một lượt gỡ on*href ở client trước khi vào DOM. Màu bị ép về currentColor nên hình luôn ra đúng tông phấn.
  • Rate limit theo IP, mặc định 12 request mỗi phút cho các endpoint tốn tiền. /api/speech đếm riêng, 90 request mỗi phút: một giáo án là sáu lần gọi, dùng chung hạn mức với model thì học sinh bị ngắt lời giữa bài.
  • Giới hạn kích thước upload và độ dài ngữ cảnh gửi lên model.
  • Với SPEECH_PROVIDER=vieneu thì lời giảng không rời khỏi máy chủ, và không có key nào để lộ.

Cần làm gì trước khi lên production

  • Rate limit: cùng vấn đề, hiện đang đếm trong RAM. Redis hoặc một reverse proxy sẽ đúng hơn.
  • CPU cho giọng đọc: một câu tốn gần đúng thời lượng của nó để tổng hợp, và vieneu_tts.py khoá lại cho chạy tuần tự — chạy song song trên cùng đám nhân chỉ làm chậm cả hai. Vài chục học sinh cùng lúc thì hàng đợi sẽ dài ra thấy rõ: tách TTS thành một service riêng để scale độc lập với web, hoặc mua GPU và bỏ backend="onnx" đi. Muốn dịch vụ trả sẵn timing thì VBee và Zalo AI cắm vào speech.py được ngay theo đúng interface hiện có.
  • Cache giọng đọc: đang là LRU trong RAM một process như store.py. Nhiều worker thì mỗi worker tổng hợp lại từ đầu.
  • Nhân bản giọng: VieNeu nhận 3–8 giây audio mẫu qua add_voice(). Muốn thầy giáo nói bằng giọng của chính thầy thì chỗ cắm là vieneu_tts.load().
  • Cache: cùng một câu hỏi trên cùng một tài liệu nên trả lại giáo án đã lưu thay vì gọi model lần nữa.
  • Chốt chế độ structured output: sau khi biết endpoint của mình hỗ trợ nấc nào, đặt thẳng LLM_STRUCTURED_MODE để bỏ hai lần gọi hỏng ở request đầu tiên.