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/, e2eapps/learn/e2e/speakRooms.spec.ts. Chương trình nói chung: SDD-038 §126.
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)
- 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.
- 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 403HOST_ONLY. Ai cũng thấy "Host:<tên>" và chỉ báo Recording / Paused. - 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.
- 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.
- 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ế
- 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ở.
- Quyền nghe suy ra từ việc CÓ MẶT TRONG PHÒNG (
speak_room_participants), không từ người tạo. - Nhà cung cấp media là một bộ chuyển đổi thay được.
- 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_PROVIDERvới bộ chuyển đổimock(§4); production đểofftớ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ườngoutputFileNametrong webhook,audio_config. Bù lại,max_secondsdo 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_configcần access key R2; tạo token R2 CHỈ đọc/ghi bucketnemo12-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ế)
- 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òngspeak_recording_tracks(learner,offset_ms). Workers AI Whisper chép từng track; câu vàospeak_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. - 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,pvàospeak_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. - 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òngsource = 'mentor'. - 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.