---
url: https://docs.nemo12.com/architecture/sdd-052-speak-rooms.md
description: >-
  Bản nháp Speak rooms (SDD-052): phòng nói theo buổi ở learn.nemo12.com/speak,
  host điều khiển ghi âm, quyết định công nghệ.
---

# SDD-052 - Speak rooms

> Nguồn: SRC-1162 (v0.1) và SRC-1166 (v0.2, luật host), chủ dự án 30.09.2026. Migration:
> `0318_speak_rooms_and_recordings.sql`, `0319_speak_room_host_and_segments.sql`.
> Mã: `workers/api/src/modules/speakRooms/`, `apps/learn/src/ielts/speakRooms/`,
> e2e `apps/learn/e2e/speakRooms.spec.ts`. Chương trình nói chung: [SDD-038 §126](sdd-038/speak-together.md).

## 1. Yêu cầu

### 1.1. v0.1 (SRC-1162)

Chủ dự án 30.09.2026: mỗi learner ghi âm được cuộc hội thoại của mình; một bản ghi thu tiếng mọi
người trong phòng; phòng có mã để vào; mọi người có chỗ nghe lại; audio lưu trên Cloudflare. Phòng
nói nằm TRÊN NEMO (`learn.nemo12.com/speak/rooms/<mã>`), thay breakout room của Zoom: giáo viên mở
đầu trên Zoom, rồi các cặp vào phòng Nemo. Đồng ý từng người, chỉ báo đỏ, xoá sau 90 ngày. Giai đoạn
2 chỉ thiết kế (§9). Bản v0.1 ghi TỰ ĐỘNG (+30 giây, 10 phút); v0.2 thay phần ấy.

### 1.2. v0.2: luật host (SRC-1166, chủ dự án 30.09.2026)

1. **Phòng gắn với một buổi** trong lịch. Chỉ mở được từ **giờ bắt đầu + 10 phút** tới **giờ kết
   thúc** của buổi; mở sớm hơn là tốn tài nguyên. Máy chủ từ chối ngoài khung ấy. Trang buổi hiện
   đếm ngược tới lúc mở được. Trang bài KHÔNG còn nút mở phòng.
2. **Mỗi phòng đúng một host** (`speak_rooms.host_learner_id`): người mở phòng. Vào bằng mã là người
   tham gia. **Chỉ host** bấm Record, Pause, Resume, Stop; người khác gọi API nhận 403 `HOST_ONLY`.
   Ai cũng thấy "Host: `<tên>`" và chỉ báo Recording / Paused.
3. **Chuyển host**: nút "Stop being host" chuyển cho người vào sớm nhất còn có mặt (và đã đồng ý,
   nếu đang ghi); hoặc "Make host" cạnh từng tên. Host rời hoặc mất nhịp tim khoảng 30 giây thì vai
   tự chuyển theo cùng luật. Không còn ai thì nút khoá ("No one to pass to"); host một mình rời phòng
   là kết thúc phòng và dừng ghi. Đang ghi thì bản ghi chạy tiếp qua lượt chuyển; từ đó chỉ host mới
   dừng được. Mỗi lượt chuyển ghi vào nhật ký phòng.
4. **Chặn an toàn ở máy chủ**: tổng thời gian đã ghi mỗi phòng tối đa **30 phút** (con số của chủ dự
   án), và tự dừng khi phòng trống hoặc buổi kết thúc. Luật đồng ý giữ nguyên: Record khoá khi có
   người có mặt chưa đồng ý; một người chưa đồng ý vào giữa chừng thì dừng ngay.
5. **Ai nghe**: MỌI người từng ở trong phòng (không chỉ người có mặt lúc ghi), phụ huynh đã liên kết
   của họ, mentor/admin. **Ai xoá**: host của phòng hoặc admin (chủ dự án gợi ý, chọn đúng như vậy).
   Xoá là xoá cho mọi người.

## 2. Nguyên tắc thiết kế

1. **Máy chủ quyết định, trình duyệt chỉ vẽ.** Nút của host là YÊU CẦU; máy chủ kiểm vai, đồng ý,
   trần 30 phút rồi mới gọi nhà cung cấp. Các lần dừng an toàn không cần client nào còn mở.
2. **Quyền nghe suy ra từ việc CÓ MẶT TRONG PHÒNG** (`speak_room_participants`), không từ người tạo.
3. **Nhà cung cấp media là một bộ chuyển đổi thay được.**
4. **Giọng trẻ em có hạn dùng.** Mọi tệp sinh ra kèm luật xoá (cùng lẽ với SRC-884).

## 3. Quyết định công nghệ

### 3.1. Ba phương án

| | A. Cloudflare RealtimeKit | B. Realtime SFU + MediaRecorder ở client | C. LiveKit Cloud |
| --- | --- | --- | --- |
| Phòng chỉ tiếng | Có (preset audio, giá audio-only) | Có, nhưng tự dựng signaling, presence | Có |
| Ghi phía máy chủ | Có: composite audio-only, hoặc từng track (`POST /recordings/track`) | Không: mỗi máy tự ghi rồi tải lên | Có (Egress) |
| Tệp nằm ở đâu | Thẳng vào R2 của ta qua `storage_config` (`type: "cloudflare"`) | R2, qua endpoint của ta | R2 qua S3-compatible |
| Webhook | `recording.statusUpdate`, ký RSA-SHA256 (`rtk-signature`) | Tự làm | Có |
| iOS Safari | WebRTC, iOS 14.5+ | MediaRecorder chỉ `audio/mp4`; dừng khi khoá màn hình hay chuyển tab | WebRTC |
| Người rớt mạng | Máy chủ vẫn ghi phần còn lại | Mất phần của máy rớt; trộn tiếng phải làm sau (Worker không chạy được ffmpeg, cần Container hoặc trộn bằng WebAudio lúc phát) | Máy chủ vẫn ghi |
| SDK | `@cloudflare/realtimekit` 2.0.2, khoảng 150 kB gzip, tải động chỉ khi vào phòng | Không cần SDK lớn | `livekit-client` |
| Nhà cung cấp mới | Không (cùng tài khoản Cloudflare) | Không | Có |

### 3.2. Chi phí (trung bình 2,5 người mỗi phòng, ghi 10 phút mỗi phòng)

v0.2: host có thể ghi tới 30 phút mỗi phòng; trường hợp xấu nhất phần ghi âm gấp ba bảng dưới
(1.000 phút-phòng/tuần, mỗi phòng ghi đủ 30 phút: khoảng 9 USD/tuần tiền xuất bản ghi).

Giá RealtimeKit: người tham gia audio-only 0,0005 USD/phút; xuất bản ghi audio-only 0,003 USD/phút.
SFU: 0,05 USD/GB egress, 1.000 GB/tháng đầu miễn phí. LiveKit Ship: khoảng 50 USD/tháng.
Nguồn: developers.cloudflare.com/realtime/realtimekit/pricing, /realtime/sfu/pricing, livekit.com/pricing
(đọc ngày 30.09.2026).

| Phương án | 100 phút-phòng/tuần | 1.000 phút-phòng/tuần |
| --- | --- | --- |
| A. RealtimeKit | 250 × 0,0005 + 100 × 0,003 = 0,43 USD/tuần (khoảng 1,85 USD/tháng) | 4,25 USD/tuần (khoảng 18 USD/tháng) |
| B. SFU + MediaRecorder | khoảng 0 (0,14 GB, trong hạn mức miễn phí) | khoảng 0 (1,4 GB) |
| C. LiveKit | 50 USD/tháng phí nền | 50 USD/tháng |

R2: khoảng 5 MB cho 10 phút ở 64 kbps; với luật xoá 90 ngày, tồn kho ở 1.000 phút-phòng/tuần là khoảng
6 GB, dưới 10 GB miễn phí.

### 3.3. Chọn A: RealtimeKit

B rẻ hơn tối đa khoảng 18 USD/tháng nhưng đổi lại toàn bộ rủi ro nằm ở phía điện thoại của trẻ: iOS
dừng MediaRecorder khi khoá màn hình, tệp hỏng khi không dừng sạch, mất phần của máy rớt mạng, và ta
phải tự dựng signaling, presence, tải lên có thử lại, rồi trộn tiếng. A giữ mọi thứ trong tài khoản
Cloudflare sẵn có, ghi phía máy chủ nên máy rớt không làm mất bản ghi, và tệp rơi thẳng vào R2 của ta.

Rủi ro của A và cách chặn:

* **Cần bật tài khoản** (App ID, preset, token): toàn bộ mã chạy sau công tắc `SPEAK_ROOMS_PROVIDER`
  với bộ chuyển đổi `mock` (§4); production để `off` tới khi chủ dự án làm các bước ở
  `.claude/memory/viec-dang-cho.md`.
* **Vài chi tiết REST chưa kiểm được trên tài khoản thật**: đường dừng ghi `PUT /recordings/{id}`
  `{action:"stop"}`, tên trường `outputFileName` trong webhook, `audio_config`. Bù lại, `max_seconds`
  do máy chủ đặt đúng bằng phần còn lại của trần 30 phút (v0.2), nên kể cả khi lệnh dừng sai, bản ghi
  vẫn tự kết thúc ở trần.
* **Khoá R2 cho RealtimeKit**: `storage_config` cần access key R2; tạo token R2 CHỈ đọc/ghi bucket
  `nemo12-content`, lưu làm secret.
* **Phát tiếng phía client do SDK lo**; cần thử trên iPhone thật trước khi mở cho learner.

## 4. Bộ chuyển đổi media

`workers/api/src/modules/speakRooms/provider.ts`, giao diện `RoomMedia`: `createMeeting`, `joinToken`,
`startRecording(maxSeconds)`, `stopRecording`.

| `SPEAK_ROOMS_PROVIDER` | Hành vi |
| --- | --- |
| `off` (mặc định production) | Tạo phòng trả 503 "Voice rooms are not switched on yet" |
| `mock` (mặc định dev/test) | Không gọi mạng; token giả; dừng ghi thì ghi một WAV im lặng 1 giây vào R2 để đường phát và xoá chạy thật |
| `realtimekit` | REST `https://api.cloudflare.com/client/v4/accounts/{CF_ACCOUNT_ID}/realtime/kit/{RTK_APP_ID}`: `POST /meetings`, `POST /meetings/{id}/participants` (preset `RTK_PRESET_NAME`, mặc định `nemo_audio_room`), `POST /recordings` với `max_seconds` và `storage_config` trỏ về `speak-rooms/<room>/` |

Phía client (`apps/learn/src/ielts/speakRooms/voice.ts`) tải `@cloudflare/realtimekit` bằng import
động; chế độ mock chỉ giữ micro để nút tắt tiếng chạy thật.

## 5. Vòng đời phòng và ghi âm do host điều khiển (v0.2)

`lifecycle.ts`: `advanceRoom` vẫn là hàm DUY NHẤT áp các luật theo thời gian; mọi route và cron 5 phút
gọi nó. Nút của host đi qua route `POST .../recording`.

### 5.1. Mở phòng

`POST /speak-rooms {session_id, consent}`: buổi phải có trong lịch (`slotById`), và
`start + 10 phút <= bây giờ < end`, nếu không trả 409 ("Rooms open 10 minutes after the session
starts" hoặc "This session has ended"). Người mở thành host và vào phòng ngay (tích đồng ý ngay ở
thẻ trên trang buổi).

### 5.2. Record, Pause, Resume, Stop, và cách làm Pause

**Pause là dừng một PHẦN, Resume là bắt đầu phần mới.** Chưa kiểm được trên tài khoản thật rằng REST
của RealtimeKit có pause/resume cho bản ghi composite, nên v0.2 không dựa vào nó: mỗi lần Record hoặc
Resume gọi `POST /recordings` (một bản ghi của nhà cung cấp), mỗi lần Pause hoặc Stop dừng bản ghi
ấy. Các phần nằm trong `speak_recording_parts` (`seq` 1, 2, 3...) dưới MỘT dòng `speak_recordings`;
trang My recordings phát lần lượt, hết phần này tự sang phần sau (`?part=N`). "Paused" không phải một
trạng thái lưu: `status='recording'` mà không có phần nào đang ghi. Nếu sau này xác nhận API
pause/resume, chỉ `provider.ts` đổi; mô hình phần vẫn đúng (một phần duy nhất).

Mỗi phòng MỘT bản ghi: Stop là kết thúc; muốn ghi tiếp thì dùng Pause.

| Nút | Điều kiện ở máy chủ |
| --- | --- |
| Record | là host; chưa có bản ghi; mọi người có mặt đã đồng ý |
| Pause | là host; có phần đang ghi |
| Resume | là host; đang tạm dừng; mọi người đã đồng ý; chưa hết 30 phút |
| Stop | là host; bản ghi đang mở (ghi hoặc tạm dừng) |

### 5.3. Dừng an toàn (không cần client)

Theo thứ tự: buổi kết thúc (`session_ended`), phòng trống (`room_empty`), đang ghi mà có người chưa
đồng ý (`consent_missing`), đã ghi đủ 30 phút (`cap_reached`). Ba lớp: mọi lượt hỏi trạng thái, cron
`*/5`, và `max_seconds` của RealtimeKit đặt bằng phần còn lại của trần 30 phút. Một phần không bao
giờ tính quá trần, kể cả khi lệnh dừng tới muộn.

### 5.4. Host và chuyển host

"Có mặt" = chưa bấm Leave và có nhịp tim trong 30 giây (client hỏi mỗi 3 giây). Người được chuyển
mặc định: người vào sớm nhất còn có mặt, trừ host; nếu đang có phần ghi thì phải đã đồng ý.
`POST .../host {to?}`: chỉ host (403 `HOST_ONLY`); `to` phải là người đang có mặt (403 nếu không);
không ai để chuyển thì 409. Host vắng thì `advanceRoom` tự chuyển. Mọi lượt ghi vào
`speak_room_events` (`host_transfer`, `host_auto_pass`, `record`, `pause`, `resume`, `stop`,
`auto_stop`, `room_end`). Phòng kết thúc khi mọi người từng vào đều đã đi, hoặc buổi kết thúc.

### 5.5. Tranh chấp

Mọi chuyển trạng thái là `UPDATE ... WHERE <trạng thái cũ>` (chuyển host: `WHERE host_learner_id =
`\<host cũ>\`\`); phần mới dùng `UNIQUE (recording_id, seq)`. Hai lần bấm cùng lúc không mở hai phần.

### 5.6. Webhook

`POST /v1/webhooks/realtimekit`, công khai, tin cậy nằm ở chữ ký RSA-SHA256 (`rtk-signature`, khoá
`RTK_WEBHOOK_PUBLIC_KEY`). `recording.statusUpdate` `UPLOADED` gắn khoá R2 vào PHẦN có
`provider_recording_id` ấy; bản ghi thành `ready` khi không còn phần nào đang xử lý. Tệp về cho một
bản ghi đã xoá thì xoá luôn.

## 6. API và giao diện

| Route (đều dưới `/v1/learners/{learnerId}`) | Việc | Quyền |
| --- | --- | --- |
| `POST /speak-rooms` `{session_id, consent}` | Mở phòng cho một buổi, người mở là host | `canAccessLearner`; khung giờ §5.1; 20/giờ |
| `POST /speak-rooms/join` `{code, consent}` | Vào phòng (người tham gia) | `canAccessLearner`; 20 lần/10 phút; tối đa 6 người |
| `GET /speak-rooms/{code}` | Trạng thái + nhịp tim; `host`, `controls`, `recording` | đã vào phòng |
| `POST /speak-rooms/{code}/consent` | Đổi ý đồng ý | đã vào phòng |
| `POST /speak-rooms/{code}/recording` `{action}` | record / pause / resume / stop | host (403 `HOST_ONLY`) |
| `POST /speak-rooms/{code}/host` `{to?}` | Chuyển host | host (403 `HOST_ONLY`); `to` phải có mặt |
| `POST /speak-rooms/{code}/token` | Token nhà cung cấp | đang có mặt |
| `POST /speak-rooms/{code}/leave` | Rời | đã vào phòng |
| `GET /speak-recordings` | Bản ghi của các phòng learner từng vào, kèm `parts`, `can_delete` | `canAccessLearner` |
| `GET /speak-recordings/{id}/audio?part=N` | Phát một phần, Range, `no-store` | learner từng ở trong phòng VÀ người gọi truy cập được learner ấy |
| `DELETE /speak-recordings/{id}` | Xoá cho mọi người | host của phòng (phiên của chính họ) hoặc admin |

Giao diện tiếng Anh (luật `/speak`):

* **Trang buổi** (đã đăng ký, buổi chưa hết): thẻ "Practice room" dưới thẻ Zoom. Trước giờ mở thì đếm
  ngược "Opens in m:ss"; tới giờ thì tích đồng ý rồi "Open a room"; hết buổi thì "closed". Luôn có
  "Join with code".
* `/speak/rooms`: nhập mã; câu nhắc mở phòng từ trang buổi.
* `/speak/rooms/<mã>`: sảnh (thử micro, tích đồng ý). Trong phòng: "Host: `<tên>`", chỉ báo Recording
  (đỏ, đã ghi / 30:00) hoặc Paused, nút của host (Record, Pause, Resume, Stop), danh sách người với
  "Make host" (chỉ host thấy), tắt tiếng, rời, "Stop being host" (khoá kèm "No one to pass to").
* `/speak/recordings`: ngày DD.MM.YYYY, mã phòng, người trong phòng, thời lượng, nghe các phần lần
  lượt, nút xoá chỉ hiện với host.

## 7. Mô hình dữ liệu

| Bảng | Vai trò |
| --- | --- |
| `speak_rooms` | mã, `created_by`, `host_learner_id` (0319), `context_kind='session'` + `context_ref` = mã buổi, `status`, `provider`, `provider_meeting_id`, `started_at`, `ended_at` |
| `speak_room_participants` | `joined_at`, `consent_at`, `left_at`, `seen_at` (nhịp tim); gốc của quyền nghe |
| `speak_recordings` | một dòng mỗi phòng: `status`, `skip_reason` (lý do dừng an toàn), `duration_seconds` (tổng các phần), `expires_at`; `planned_*` giữ lại từ v0.1 (v0.2: lúc bấm Record và giờ hết buổi) |
| `speak_recording_parts` (0319) | từng đoạn giữa hai lần Pause: `seq`, `provider_recording_id`, `r2_key`, `duration_seconds` |
| `speak_room_events` (0319) | nhật ký phòng: chuyển host, các nút, dừng an toàn |
| `speak_recording_participants` | ai có mặt lúc bấm nút ghi (dữ liệu cho giai đoạn 2), không còn dùng để phân quyền |
| `speak_recording_deletions` | nhật ký xoá |
| `speak_recording_tracks`, `_segments`, `_feedback`, `_comments` | giai đoạn 2 (§9), chưa có mã ghi |

## 8. Quyền riêng tư, đồng ý, lưu giữ (learner vị thành niên)

* **Đồng ý của từng người, trước khi vào**; nút Join / Open a room khoá tới khi tích.
* **Record khoá khi có người có mặt chưa đồng ý**; người ấy vào giữa chừng thì dừng ngay.
* **Chỉ báo cho mọi người**: Recording (đỏ) hoặc Paused, kèm thời gian đã dùng trên 30:00.
* **Ai nghe**: mọi người từng ở trong phòng, phụ huynh trong family của họ, mentor/staff/admin (quyền
  mượn, ghi `audit_log`). Lý do chủ dự án: đoạn hội thoại thuộc về cả nhóm.
* **Ai xoá**: host của phòng hoặc admin; xoá cho cả phòng (một tệp trộn không tách được giọng).
  Phụ huynh không xoá thay được (chọn theo gợi ý của chủ dự án, 30.09.2026).
* **Trần 30 phút** mỗi phòng là con số chủ dự án chọn.
* **90 ngày**: cron hằng ngày xoá mọi phần trong R2 rồi D1; lưới thứ hai là quy tắc vòng đời R2 cho
  tiền tố `speak-rooms/` ở 91 ngày.

## 9. Giai đoạn 2 (chỉ thiết kế)

1. **Bản chép theo người nói**: bật ghi từng track song song với composite
   (`POST /recordings/track`), mỗi tệp một dòng `speak_recording_tracks` (learner, `offset_ms`). Workers
   AI Whisper chép từng track; câu vào `speak_recording_segments` (learner, `start_ms`, `end_ms`). Không
   cần tách giọng bằng máy vì mỗi track đã là một người.
2. **Nhận xét AI theo tiêu chí IELTS Speaking**: từ segments của MỘT learner, sinh nhận xét bốn tiêu chí
   `fc`, `lr`, `gra`, `p` vào `speak_recording_feedback` (`source = 'ai'`). Không hiện "band" cho
   learner trước khi mentor duyệt, cùng luật với SDD-038.
3. **Mentor nghe và bình luận**: `speak_recording_comments` (`at_ms` để gắn vào một mốc trong tệp);
   mentor chốt nhận xét thì thêm dòng `source = 'mentor'`.
4. Xoá một bản ghi xoá luôn tracks và segments (đã có trong `deleteRecording`).

## Trace

| Yêu cầu | Mục |
| --- | --- |
| Phòng trên Nemo, vào bằng mã (SRC-1162) | §1.1, §6 |
| Audio trên Cloudflare (R2), nghiên cứu công nghệ, chi phí (SRC-1162) | §3, §4 |
| Phòng gắn buổi, mở từ start + 10 phút, đếm ngược (SRC-1166) | §1.2, §5.1, §6 |
| Một host, chỉ host Record/Pause/Stop, 403 (SRC-1166) | §1.2, §5.2, §6 |
| Chuyển host, tự chuyển khi host vắng, nhật ký (SRC-1166) | §5.4 |
| Trần 30 phút, dừng khi phòng trống hoặc hết buổi, đồng ý (SRC-1166) | §5.3, §8 |
| Pause/Resume: cách làm (SRC-1166) | §5.2 |
| Quyền nghe mọi người từng trong phòng, host/admin xoá (SRC-1166) | §6, §8 |
| Giai đoạn 2: bản chép theo người nói, nhận xét AI, mentor (SRC-1162) | §9 |

| Phòng trên Nemo, vào bằng mã, mở từ trang buổi hoặc trang bài | §1, §6 |
| Ghi mọi người có mặt, +30 s, 10 phút, tự dừng, chặn ở máy chủ | §5, §5.1 |
| Chỗ nghe lại cho mọi người | §6 |
| Audio trên Cloudflare (R2) | §3, §4 |
| Quyền nghe: người trong phòng, mentor/admin, phụ huynh | §6, §8 |
| Đồng ý, chỉ báo đỏ, 90 ngày, tự xoá giọng mình | §8 |
| Nghiên cứu công nghệ, so sánh, chi phí | §3 |
| Giai đoạn 2: bản chép theo người nói, nhận xét AI, mentor | §9 |

## Bật trên production (SRC-1168, 30.09.2026)

Chủ dự án đã có app RealtimeKit `nemo12-speak-rooms` (App ID `ee728643-2418-4436-9af3-8097082c1056`), preset Voice `nemo_audio_room`, webhook `recording.statusUpdate` tới `https://api.nemo12.com/v1/webhooks/realtimekit` và quy tắc R2 `speak-rooms-expiry` (91 ngày). `SPEAK_ROOMS_PROVIDER` chuyển `off` → `realtimekit` sau khi chủ dự án đặt đủ 6 secret trên dashboard.
