Bấm "Dừng" mà mất luôn câu cuối: dừng một phiên nhận dạng giọng nói realtime qua WebSocket cho đúng
Mình đang làm một web app hiện phụ đề và bản dịch theo thời gian thực ngay trong trình duyệt: bạn nói, chữ hiện lên, từng câu được dịch sang ngôn ngữ khác. Phần "bắt đầu" thì ai cũng chăm chút: xin quyền micro, mở WebSocket, đẩy audio lên server. Phần "dừng" thì thường chỉ là một dòng ws.close().
Chính dòng đó đã gây ra một lỗi khó chịu: người dùng nói một câu ngắn rồi bấm Dừng, màn hình không hiện gì cả. Nói dài thì không sao, chỉ mất câu cuối nên ít ai để ý. Nhưng với người dùng lần đầu thử, kiểu "Xin chào, tôi đang thử" rồi bấm Dừng, thì một màn hình trống nghĩa là "app này không chạy".
Bài này ghi lại vì sao câu cuối bị mất và cách thiết kế một "cái bắt tay" khi dừng để không mất chữ nào.
Pipeline trông như thế nào
Trình duyệt (micro / âm thanh tab)
│ PCM 16 kHz, binary frame qua WebSocket
▼
Server Node.js ──► Dịch vụ nhận dạng giọng nói streaming (cũng qua WebSocket)
▲ │
│ kết quả tạm (partial) │ và kết quả cuối (final)
└─────────────────────────┘
│
├─► cắt câu ─► dịch ─► lưu lịch sử
▼
Trình duyệt hiện phụ đề + bản dịch
Dịch vụ nhận dạng trả về hai loại kết quả:
- Partial: đoán nhanh, liên tục bị sửa. Dùng để hiện chữ "đang gõ" cho người dùng thấy app đang nghe.
- Final: kết quả chốt. Chỉ final mới được cắt thành câu, đem dịch và lưu.
Điểm mấu chốt: final luôn đến sau audio một khoảng. Bộ nhận dạng cần nghe thêm một chút ngữ cảnh phía sau (thường từ vài trăm mili-giây đến vài giây, tùy cấu hình độ trễ tối đa) mới dám chốt một từ. Nghĩa là tại thời điểm người dùng bấm Dừng, câu cuối gần như chắc chắn mới chỉ có partial.
Cách dừng "ngây thơ" (và cũng là bản cũ của mình)
Phía client:
function stop() {
stopCapture(); // tắt micro / AudioWorklet
ws.send(JSON.stringify({ type: 'stop' }));
setTimeout(() => ws.close(), 3000); // chờ 3 giây cho chắc rồi đóng
}
Phía server:
clientWs.on('message', (data, isBinary) => {
if (!isBinary) return; // text frame không phải audio → bỏ qua
upstream.send(data, { binary: true });
});
clientWs.on('close', () => {
isStopped = true; // từ đây bỏ qua mọi kết quả
upstream.send(JSON.stringify({ message: 'end_of_stream' }));
});
Nhìn qua thì hợp lý: client báo dừng, chờ 3 giây, đóng; server thấy client đóng thì báo cho dịch vụ nhận dạng là hết audio.
Có hai lỗi chồng lên nhau:
- Lệnh
stopkhông bao giờ tới nơi. Dòngif (!isBinary) return;được viết để không lỡ tay đẩy JSON điều khiển vào luồng audio. Nhưng nó cũng nuốt luôn{type:'stop'}. Code xử lý stop phía server tồn tại nhưng không bao giờ chạy. - Thứ tự bị đảo. Thông báo "hết audio" chỉ được gửi sau khi client đã đóng, lúc
isStoppedđã làtrue. Dịch vụ nhận dạng nhận được tín hiệu, chốt câu cuối, gửi final về… và server bỏ qua nó. Kể cả nếu không bỏ qua thì cũng chẳng còn kết nối nào để gửi xuống trình duyệt.
Kết quả: câu cuối chỉ tồn tại dưới dạng partial, và partial thì không được dịch, không được lưu.
Dừng đúng cách: một cái bắt tay ba bước
Ý tưởng: người dùng bấm Dừng không có nghĩa là đóng kết nối ngay. Nó có nghĩa là "không gửi thêm audio nữa, hãy xử lý nốt những gì đã nghe, xong thì báo mình".
Client Server Dịch vụ nhận dạng
│ tắt micro │ │
│── {type:'stop'} ─────────► │ ngừng nhận audio, ngừng tính phí│
│ │── end_of_stream (+ số chunk) ──► │
│ │ ◄──────────── final câu cuối ────│
│ ◄──── câu cuối + bản dịch ─│ │
│ │ ◄──────────── end_of_transcript ─│
│ │ chờ các bản dịch đang chạy │
│ ◄──── {type:'stopped'} ────│ │
│ đóng WebSocket │ │
Server
const STOP_FINAL_WAIT_MS = 3000; // chờ tín hiệu "đã chốt hết" từ dịch vụ nhận dạng
const STOP_SENTENCE_WAIT_MS = 5000; // chờ các câu đang dịch / đang lưu
let stopping = false, isStopped = false, stopTimer = null, stopPromise = null;
const inflight = new Set(); // Promise của từng câu đang dịch + lưu
clientWs.on('message', (data, isBinary) => {
if (!isBinary) { // text frame = lệnh điều khiển
let ctl = null;
try { ctl = JSON.parse(data.toString()); } catch {}
if (ctl?.type === 'stop') beginStop();
return;
}
if (stopping || isStopped) return; // sau khi bấm Dừng: không nhận audio nữa
sendAudio(data);
});
function beginStop() {
if (stopping || isStopped) return;
stopping = true;
stopBilling(); // người dùng không trả tiền cho thời gian chờ
sendControl({ message: 'end_of_stream', last_seq_no: sentChunks + queuedChunks.length });
stopTimer = setTimeout(() => finishStop('timeout'), STOP_FINAL_WAIT_MS);
}
function finishStop(reason) {
if (stopPromise) return stopPromise; // có thể bị gọi từ nhiều nơi
clearTimeout(stopTimer);
stopPromise = (async () => {
if (clientWs.readyState === WebSocket.OPEN) flushTail(); // đoạn cuối chưa có dấu chấm câu
if (inflight.size) {
await Promise.race([
Promise.allSettled([...inflight]),
new Promise(r => setTimeout(r, STOP_SENTENCE_WAIT_MS)),
]);
}
isStopped = true;
send({ type: 'stopped' });
})();
return stopPromise;
}
upstream.on('message', (raw) => {
const msg = JSON.parse(raw);
if (msg.message === 'end_of_transcript' && stopping) finishStop('eot');
if (msg.message === 'final' && !isStopped) handleFinal(msg); // vẫn xử lý trong lúc "stopping"
// ...
});
upstream.on('close', () => {
if (stopping) { // upstream đóng trước: vẫn gửi nốt rồi mới đóng client
finishStop('upstream-close').finally(() => clientWs.close());
return;
}
clientWs.close();
});
Lưu ý sự khác nhau giữa hai cờ:
stopping: đã bấm Dừng, không nhận audio mới nhưng vẫn xử lý kết quả.isStopped: đã xong hẳn, từ đây mới bỏ qua mọi thứ.
Bản cũ chỉ có một cờ, nên không có khoảng "đang dừng" ở giữa, và câu cuối rơi đúng vào khoảng đó.
Client
function stop() {
stopCapture();
if (ws?.readyState !== WebSocket.OPEN) { ws?.close(); return; }
ws.send(JSON.stringify({ type: 'stop' }));
const closingWs = ws; // người dùng có thể bấm Bắt đầu lại trước khi timer chạy
clearTimeout(stopTimer);
stopTimer = setTimeout(() => {
if (closingWs.readyState === WebSocket.OPEN) closingWs.close();
}, 9000); // dự phòng, phải dài hơn tổng thời gian chờ của server
}
ws.addEventListener('message', (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === 'stopped') {
clearTimeout(stopTimer);
ev.target.close(1000, 'stopped');
return;
}
// ... hiện câu, hiện bản dịch như bình thường
});
Client không còn đoán "3 giây chắc là đủ". Nó đóng khi server nói xong. Timer chỉ còn là lưới an toàn khi server hoặc mạng có vấn đề.
Những chi tiết dễ sót
1. Text frame và binary frame là hai kênh khác nhau. Với thư viện ws trên Node, tham số isBinary cho biết frame là gì. Đừng chặn cả nhóm text frame chỉ vì sợ lẫn vào audio; hãy parse và chỉ xử lý đúng những lệnh mình hỗ trợ, phần còn lại bỏ qua như cũ.
2. Thứ tự hàng đợi khi upstream chưa sẵn sàng. Kết nối tới dịch vụ nhận dạng mất một chút thời gian. Trong lúc đó audio từ trình duyệt được xếp hàng. Nếu người dùng bấm Dừng ngay trong khoảng này, lệnh end_of_stream cũng bị xếp hàng. Khi kết nối mở, phải gửi hết audio trước, rồi mới tới lệnh điều khiển, nếu không dịch vụ nhận được "hết rồi" trước khi nhận được chút audio nào. Và last_seq_no phải tính cả những chunk còn trong hàng đợi.
upstream.on('open', () => {
upstream.send(JSON.stringify(startMessage));
while (queuedAudio.length) { upstream.send(queuedAudio.shift(), { binary: true }); sentChunks++; }
while (queuedControl.length) upstream.send(queuedControl.shift()); // end_of_stream nằm ở đây
});
3. Câu cuối thường không có dấu chấm. Mình cắt câu theo dấu câu trong kết quả final, hoặc khi người nói ngừng một lúc. Câu cuối trước khi bấm Dừng hay bị cụt, không có dấu chấm và cũng chưa kịp có khoảng lặng. Khi dừng phải "xả" phần chữ còn tồn đọng thành một câu và đem dịch luôn (flushTail() ở trên).
4. Ngừng tính phí ngay lúc bấm. Thời gian chờ câu cuối là việc của hệ thống, không phải của người dùng. Nếu app tính phút sử dụng, dừng đồng hồ trong beginStop(), đừng đợi tới stopped.
5. Đừng làm việc thừa khi client đã đi mất. Nếu người dùng đóng tab, hoặc một client khác (ví dụ extension) gửi stop rồi đóng kết nối ngay, thì không ai nhận câu cuối nữa. Kiểm tra clientWs.readyState trước khi flush để không tốn một lượt dịch cho câu không ai đọc.
6. Các timeout phải lồng nhau đúng chiều. Server chờ tối đa 3 s cho kết quả cuối và 5 s cho bản dịch, tức khoảng 8 s. Timer dự phòng ở client phải dài hơn (9 s). Nếu client đóng sớm hơn server thì quay về đúng lỗi ban đầu.
7. Giữ tham chiếu tới socket cũ. Người dùng hay bấm Dừng rồi Bắt đầu lại ngay. Nếu timer dự phòng gọi ws.close() với biến ws toàn cục, nó có thể đóng nhầm kết nối mới. Bắt closingWs lại trong closure.
Kiểm thử thế nào
Những tình huống nên thử mỗi khi sửa phần dừng:
- Nói đúng một câu ngắn ("Xin chào") rồi bấm Dừng ngay lập tức → câu và bản dịch phải hiện ra.
- Bấm Bắt đầu rồi bấm Dừng khi kết nối còn đang mở → không lỗi, không treo nút.
- Dừng rồi bắt đầu lại liên tục vài lần → không có kết nối nào bị đóng nhầm.
- Nói một câu dài không có điểm ngắt rồi dừng giữa chừng → phần đã nói vẫn được dịch.
- Đóng tab ngay sau khi bấm Dừng → log server không có lượt dịch thừa.
- Giả lập mạng chậm trong DevTools →
stoppedvẫn đến trước timer dự phòng.
Ở log server, mình in ra lý do kết thúc (eot, timeout, upstream-close). Nếu timeout xuất hiện nhiều thì nghĩa là thời gian chờ đang quá ngắn so với độ trễ thật của bộ nhận dạng.
Tóm lại
Với hệ thống streaming, "dừng" không phải một sự kiện mà là một quá trình: ngừng nhận đầu vào → báo cho tầng dưới → chờ tầng dưới trả nốt → báo lại cho tầng trên → mới đóng. Chỉ cần một bước nhảy cóc là mất đúng phần dữ liệu người dùng vừa nhìn thấy cuối cùng.
Toàn bộ phần trên đang chạy trong LiveToka, công cụ phụ đề và dịch giọng nói trực tiếp trên trình duyệt. Bạn có thể thử bản tiếng Việt tại https://livetoka.com/vi/ (nói một câu rồi bấm Dừng ngay xem câu cuối có còn không 🙂).
All rights reserved