Nghe & chép lại (SRC-668)
Chỉ đạo 2026-09-02: trong course IELTS Core, chặng Investigate của mỗi bài có một bài tập nghe. Learner chọn một trong ba đoạn, mỗi đoạn không quá 2 phút, gõ lại từng câu vào một ô riêng, bấm Kiểm tra để nhận phần trăm trùng khớp so với câu gốc, và bấm vào đầu mỗi ô để nghe lại đúng câu đó. Mỗi đoạn gồm 6-7 câu.
Mỗi đoạn một trang riêng
Chỉ đạo 2026-09-02: "Click vào mỗi item đó thì ra trang riêng. Trang riêng chỉ có đúng nội dung cần thiết được hiển thị ra thôi."
Chặng Tìm hiểu chỉ liệt kê các đoạn (biểu tượng loa + tên chủ đề + độ dài); bấm là sang .../lessons/<n>/investigate/clips/<k>, nơi chỉ có tên đoạn, đường quay lại và bài tập. Lý do: chép chính tả là việc đòi tập trung liên tục vài phút — bảy ô nhập nằm giữa một trang còn có hiểu lầm, lỗi hay mắc và bài kiểm thì mỗi lần learner ngẩng lên là gặp một thứ khác rủ đi chỗ khác.
Bốn chặng của bài cũng có địa chỉ riêng (/overview, /connect, /investigate, /synthesize) và đã chuyển từ hàng tab trên đầu sang left panel, cùng chỗ với cây Unit/Lesson.
Mỗi cụm cũng là một mục menu (chỉ đạo 2026-09-02): dưới mục chặng đang mở, panel liệt kê các đoạn nghe rồi tới các cụm đọc — .../investigate/clip-2, .../investigate/misconceptions, /errors, /interventions, /checks. Bấm vào thì thân trang chỉ còn cụm ấy, có đường quay lại cả chặng và không có nút "xong chặng" (chặng chưa xong, learner mới đọc một mẩu).
Địa chỉ cũ /lessons/<n> không kèm chặng vẫn mở ra chặng đầu tiên có nội dung, và /investigate không kèm cụm vẫn mở cả chặng — xem resolveStage và resolvePart trong apps/learn/src/lessonStages.ts. Dạng cũ /investigate/clips/2 được nhận vào và đổi sang /investigate/clip-2: một địa chỉ đã phát ra ngoài thì không được phép chết.
Vì sao chấm bằng thuật toán, không bằng model
Chấm chính tả là so hai chuỗi từ. Khoảng cách sửa (Levenshtein) trên mức từ cho ra đúng con số learner cần, chạy trong một phần nghìn giây, và cùng một câu luôn ra cùng một điểm. Gọi model ở đây thì mỗi cú bấm tốn tiền, tốn một giây chờ, và điểm dao động giữa hai lần bấm giống hệt nhau.
Chuẩn hoá trước khi so: bỏ viết hoa, bỏ dấu câu, gộp khoảng trắng, và ghép dấu nháy lại (don't = dont). Bài này kiểm kỹ năng nghe, không kiểm chấm câu.
Mã: workers/api/src/modules/dictation/score.ts.
Vì sao chấm ở máy chủ
Thuật toán chạy đâu cũng ra một kết quả, nhưng chấm ở trình duyệt nghĩa là phải gửi câu gốc xuống trước khi learner gõ. Một bài chép chính tả có sẵn đáp án trong tab Network thì không còn là bài chép chính tả. Nên GET .../dictation trả mốc thời gian và số từ mà không trả một chữ nào; câu gốc chỉ rời máy chủ trong phản hồi của POST .../check.
Vì sao mốc từng câu là chính xác, không phải ước lượng
Máy đọc từng câu thành một file riêng rồi mới nối lại thành đoạn. Mốc bắt đầu của câu k là tổng độ dài các câu trước cộng khoảng lặng — đúng đến từng mẫu (sample), không phải đo.
Đường còn lại là đọc cả đoạn một lượt rồi đi dò mốc: đó là bài toán forced alignment, cần thêm một model nữa, và lệch vài trăm mili-giây là learner nghe lẹm mất chữ đầu của chính câu mình đang gõ.
Nối ở mức PCM thô chứ không ghép file nén: ghép hai MP3 là ghép hai luồng frame có phần đệm riêng — nghe thì được, nhưng độ dài cộng dồn lệch dần, mà độ dài cộng dồn chính là cái mốc.
Ngữ liệu lấy từ đâu
Chỉ đạo 2026-09-02: phim hoạt hình (Up · Kung Fu Panda · Inside Out · Finding Nemo) và đặc trưng văn hoá quốc gia (Nhật · Hàn · Trung · Mỹ · Anh · Pháp · Đức · Việt). Mười hai chủ đề cho một unit ba bài, nên ba chủ đề để dành — lặp chủ đề trong cùng một bài thì learner chọn theo trí nhớ chứ không theo hứng, và cổng kiểm chặn đúng việc đó.
Cùng ngày, chủ dự án gỡ cấm SRC-635 cho riêng ngữ liệu nghe: đoạn nghe được soạn tay. Điều KHÔNG đổi là cổng kiểm — luật của một đoạn nằm ở scripts/lib/dictation-rules.mjs và được gọi từ cả hai đường, máy sinh lẫn npm run check:docs. Ràng buộc chỉ áp cho một nửa số bài thì không còn là ràng buộc.
Đường đi của một đoạn
| Bước | Ai làm | Ra cái gì |
|---|---|---|
| 1. Chủ đề và ràng buộc | người | content/dictation/topics.json |
| 2. Soạn chữ | người, hoặc npm run dictation:forge (model qua AI Gateway) | content/dictation/<lesson-id>.json, status: draft |
| 3. Rà soát | chủ dự án | sửa thẳng vào file bản thảo |
| 4. Sinh tiếng + nạp | nút Sinh tiếng ở tab Dictation của admin.nemo12.com | audio trong R2 nemo12-content, hàng trong D1, status giữ nguyên draft |
| 5. Duyệt | nút Duyệt ở cùng trang | status thành published |
Learner chỉ thấy published, và chỉ thấy đoạn đã có audio: một đoạn có chữ mà chưa có tiếng thì API bỏ qua, vì một nút bấm không kêu tệ hơn hẳn một bài tập chưa xuất hiện.
Cổng kiểm (số câu, số từ, cấm chữ số, cấm ký tự không nghe được, trần thời lượng ước tính) chạy ngay trong máy sinh chứ không để dành cho người duyệt: bản thảo hỏng ràng buộc mà lọt vào khu rà soát sẽ ăn mất đúng thứ quý nhất ở đó, là sự chú ý của người đọc. Cùng cổng ấy chạy trong npm run check:docs để nội dung soạn tay không đi cửa sau.
Vì sao sinh tiếng chạy trong worker api, không phải trong một script
Chỉ đạo 2026-09-02: "Cần gì khoá Cloudflare. Cần tạo api.nemo12.com chứ."
Worker nemo12-api đã có sẵn binding AI (qua AI Gateway), CONTENT (R2) và DB (D1). Chạy ở đó thì không có khoá API nào phải phát cho ai, không có khoá nào để lộ, và không có máy nào phải cấu hình xong mới sinh được tiếng. Bản script chạy ngoài đã bị gỡ bỏ chứ không giữ song song: hai đường sinh cùng một thứ thì sớm muộn lệch nhau, và cái lệch sẽ hiện ra dưới dạng một đoạn audio không khớp mốc thời gian.
Một đoạn một lượt gọi, không phải cả bài một lượt: đọc bảy câu mất cỡ mười lăm giây, ba đoạn là bốn nhăm giây trong một request — và một request dài như thế đứt giữa chừng thì không ai biết nó đã ghi tới đâu. Từng đoạn thì mỗi lượt hoặc xong hẳn hoặc chưa làm gì.
Nội dung đi trong thân request, không nằm trong bundle của worker: bản thảo còn đang được sửa ở khu rà soát, mà nhét nội dung vào worker thì mỗi lần sửa một dấu phẩy lại phải deploy lại cả API đang phục vụ learner. Trang admin đọc thẳng content/dictation/ qua alias @dictation và gửi đi.
Sinh lại tiếng không đụng tới status: đó là việc kỹ thuật, còn published là chữ ký của người duyệt. Một cú bấm "sinh lại" không được phép gỡ chữ ký ấy, mà cũng không được phép tự đóng nó.
Định dạng ra là WAV 16 kHz mono. Mười sáu chứ không phải hai tư vì đây là tiếng nói và tai không phân biệt được hai mức ấy trên giọng người; đổi lại file nhẹ đi một phần ba, mà learner Việt Nam phần lớn nghe trên 4G.
Bước 2 chỉ cần CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_API_TOKEN khi chạy máy sinh chữ; bước 4 không cần khoá nào.
Giọng đọc
@cf/deepgram/aura-2-en, giọng apollo — giọng nam Mỹ, nhịp phát thanh, đúng loại tiếng learner gặp trong IELTS Listening.
Bản 2, không phải bản 1: apollo chỉ tồn tại ở Aura-2. Aura-1 nhận đúng mười hai giọng và từ chối thẳng mọi tên khác (enum apollo not in …). Ghi lại vì sẽ có ngày ai đó đọc chữ "aura" trong tài liệu rồi hạ về bản 1 cho gọn. Đổi giọng thì phải sinh lại cả đoạn: nửa đoạn giọng này nửa đoạn giọng kia là một bài nghe hỏng.
Song ngữ tới đâu rồi
| Tầng | Cột _en | Migration |
|---|---|---|
| Tên course/unit/lesson, guiding question, big idea, essential question, outcome, Performance Task | có | 0181 + 0191 |
| Câu chuyện mở bài, hiểu lầm thường gặp, lỗi hay mắc | có | 0194 |
| Cách gỡ, học liệu, bài kiểm, điều đọng lại | chưa | — |
Cột _en để NULL cho tới khi có bản dịch, và chính cái NULL ấy là thứ đếm được: không có cột thì một ô đang chứa tiếng Việt trông y hệt một ô đã dịch xong, và nó nằm lại đó mãi mãi. Trang tự lùi về tiếng Việt khi thiếu (tx(vi, en)), nên thêm cột rỗng không làm hỏng gì.
Người soạn bản dịch hiểu lầm phải giữ đúng khuôn "<hiểu lầm> For example: <ví dụ> But: <đính chính>" — bộ tách splitMisconception nhận cả nhãn tiếng Việt lẫn tiếng Anh. Chỗ sửa đúng vẫn là cho xưởng sinh ra ba cột thay vì một; chừng nào chưa làm thì hai bản phải hỏng giống nhau, chứ không được hỏng khác nhau.
Bảng và mã
- Bảng:
migrations/0190_dictation.sql—dictation_sets,dictation_lines,dictation_attempts. Câu gốc nằm ở bảng riêng vì mốc thời gian được trả cho trình duyệt còn chữ thì không: hai loại dữ liệu có quyền đọc khác nhau thì không ở chung một ô. - API:
workers/api/src/modules/dictation/—build.tslà máy sinh tiếng. - Trang vận hành:
apps/admin/src/pages/Dictation.tsx, tab Dictation. - Giao diện:
apps/learn/src/Dictation.tsx—DictationListlà danh sách trong chặng Tìm hiểu,DictationPagelà trang riêng của một đoạn. - Cổng:
scripts/check-dictation.mjstrongnpm run check:docs, luật ởscripts/lib/dictation-rules.mjs.
Nhãn tiếng Việt của chủ đề nằm ở cột topic_label_vi trong D1, không nằm trong một bảng tra ở mã giao diện: thêm chủ đề mới mà quên sửa bảng tra thì learner thấy đúng cái slug tiếng Anh trên màn hình, và đó là loại lỗi không cổng nào bắt được vì mã vẫn chạy đúng.
dictation_attempts không lưu chữ learner gõ, chỉ lưu phần trăm từng câu. Thứ cần biết là ngữ liệu chỗ nào quá khó, không phải con nhà ai gõ sai chữ gì.