---
url: https://docs.nemo12.com/reference/dictation.md
description: >-
  Bài tập Nghe và chép lại ở chặng Investigate của course IELTS Core: ba đoạn
  dưới 2 phút, gõ từng câu, nhận phần trăm trùng khớp.
---

# 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.ts` là 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` — `DictationList` là danh sách trong chặng Tìm hiểu,
  `DictationPage` là **trang riêng** của một đoạn.
* Cổng: `scripts/check-dictation.mjs` trong `npm 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ì.
