Spaces:
Sleeping
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.
requiresvàblockers.requiresbắ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.blockersloạ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đượcCOPYvào dưới quyền root, còn container chạy bằng userlophoc. Khôngchownthìaudio_cache.put()gặpOSError, 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 -vrồ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.
requiresvàblockers.requiresbắ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.blockersloạ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=1nạ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.
Có 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:
- 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. - 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. - 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ắnboundarytheo từ, Safari gần như không bắn. Nênspeech.jschạy một bộ đếm ký tự theo tốc độ ước lượng và chỉ dùngboundaryđể 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*vàhrefở client trước khi vào DOM. Màu bị ép vềcurrentColornê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=vieneuthì 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.pykhoá 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àospeech.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.