Nhập giọng nói trên trình duyệt: Những lỗi ít ai cảnh báo
Thêm câu trả lời bằng giọng nói vào ứng dụng web với MediaRecorder và Web Speech API, đồng thời xử lý lỗi race condition, interim result và timing.
Long Nguyen
Lập trình viên Fullstack · Kỹ sư AI · Nhà nghiên cứu
Vì sao phần giọng nói thường bị bỏ qua trong các tutorial?
Một buổi phỏng vấn mà bạn phải gõ câu trả lời thực ra chưa phải là luyện phỏng vấn. Mục đích của ứng dụng này là giúp người dùng trả lời thành tiếng, vì vậy phần này bổ sung tính năng nhập giọng nói bằng công nghệ nhận dạng giọng nói trong trình duyệt — tính năng khiến ứng dụng trở nên thực tế, nhưng cũng là phần mà hầu hết tutorial thường lặng lẽ bỏ qua. Ở phần 7, pipeline đã tạo câu hỏi; giờ ứng viên sẽ nói câu trả lời của mình.
Trình duyệt cung cấp hai API cho việc này: MediaRecorder để ghi lại âm thanh thực tế và Web Speech API (SpeechRecognition) để chuyển giọng nói thành văn bản theo thời gian thực. Từng API riêng lẻ đều khá đơn giản. Nhưng khi chạy đồng thời và ổn định trên các thiết bị thực tế, nhiều điểm dễ phát sinh lỗi bắt đầu xuất hiện — và phần này chủ yếu tập trung vào chúng, bởi đó là khác biệt giữa “chạy được trong bản demo” và “hoạt động ổn định với người dùng thật”.
Ghi âm và nhận dạng giọng nói đồng thời
Mục tiêu là giữ lại cả bản ghi âm thực tế (để chuyển đổi chính xác hơn về sau) lẫn bản chép lời trực tiếp (để hiển thị phản hồi ngay trên màn hình). Trước tiên, chúng ta khởi động microphone, sau đó mới bắt đầu nhận dạng giọng nói:
async function startAnswerCapture() {
let micOk = false;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
chunks = [];
mediaRecorder.ondataavailable = (e) => chunks.push(e.data);
mediaRecorder.start();
micOk = true;
} catch (err) {
mediaRecorder = null; // no mic — we'll fall back to transcript only
}
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (SR) {
recognition = new SR();
recognition.lang = 'en-US';
recognition.continuous = true;
recognition.interimResults = true;
// ... handlers below
recognition.start();
}
return micOk;
}
Thứ tự này không phải chi tiết mang tính hình thức. Hãy lấy quyền truy cập microphone trước khi bắt đầu nhận dạng. Nếu nhận dạng giọng nói đã chạy khi bạn gọi getUserMedia, Chrome sẽ hủy phiên nhận dạng — và làm vậy một cách âm thầm, khiến bản chép lời bị trống mà không có lỗi nào. Xử lý xong quyền microphone trước sẽ tránh hoàn toàn vấn đề này. Đây là lỗi đầu tiên, và bạn thường chỉ nhận ra nó sau khi gặp phải trong môi trường production.
Lỗi thường gặp: Không thể bỏ qua kết quả tạm thời
Bạn có thể muốn chỉ giữ các kết quả “final” và bỏ qua kết quả tạm thời — vì “final” nghe có vẻ như phiên bản sạch sẽ, hoàn tất. Nhưng đó là một cái bẫy. Chrome chỉ xác nhận một kết quả sau khi phát hiện thấy một khoảng lặng. Nếu ứng viên nói liên tục cho đến lúc nhấn gửi mà không dừng lại ở cuối, đoạn nói cuối cùng sẽ không bao giờ được xác nhận — và câu trả lời có thể hoàn toàn trống.
recognition.onresult = (e) => {
interimTranscript = '';
for (let i = e.resultIndex; i < e.results.length; i++) {
if (e.results[i].isFinal) {
finalTranscript += e.results[i][0].transcript + ' ';
} else {
interimTranscript += e.results[i][0].transcript + ' '; // keep these!
}
}
};
Vì vậy, chúng ta theo dõi riêng phần văn bản tạm thời và không bao giờ loại bỏ nó — phần này được hiển thị trực tiếp, sau đó gộp vào bản chép lời cuối cùng khi phiên kết thúc. Cách sửa khá đơn giản; nhưng thường bạn chỉ phát hiện ra mình cần nó sau khi đã làm mất hàng loạt câu trả lời thực tế do biểu mẫu được gửi đi mà không có nội dung.
Lỗi thường gặp: Nhận dạng tự dừng
Ngay cả khi ở chế độ continuous, SpeechRecognition vẫn tự kết thúc phiên sau một khoảng im lặng. Khi ứng viên dừng lại để suy nghĩ giữa câu trả lời, nhận dạng có thể âm thầm dừng mà họ không biết. Vì vậy, chúng ta khởi động lại nó — nhưng chỉ khi câu trả lời vẫn đang được ghi nhận — đồng thời giữ lại các từ tạm thời từ phiên vừa kết thúc, vì chúng sẽ không còn cơ hội được xác nhận:
recognition.onend = () => {
if (interimTranscript.trim()) {
finalTranscript += interimTranscript.trim() + ' ';
interimTranscript = '';
}
if (recognitionActive) {
try { recognition.start(); } catch (err) { /* already restarting */ }
} else if (onRecognitionEnd) {
onRecognitionEnd();
}
};
Biến recognitionActive đóng vai trò bảo vệ: khi câu trả lời còn đang hoạt động, hệ thống sẽ tự khởi động lại; sau khi chúng ta chủ động dừng, nó sẽ để phiên kết thúc và báo hiệu cho phần đang chờ.
Lỗi thường gặp: Dừng nhận dạng là một race condition
Điểm khó chịu nhất xuất hiện ở thời điểm kết thúc. Khi gọi recognition.stop(), các kết quả cuối cùng không xuất hiện đồng bộ — chúng được phát ra sau khi lệnh dừng được gọi, muộn hơn một chút. Nếu đọc bản chép lời ngay lập tức, bạn sẽ mất những từ cuối trong mọi câu trả lời. Vì vậy, thao tác dừng phải chờ cho đến khi phiên thực sự kết thúc, đồng thời có timeout giới hạn để trình duyệt không phát sự kiện onend cũng không thể khiến ứng dụng bị treo:
function stopRecognitionAndWait() {
return new Promise((resolve) => {
recognitionActive = false;
const rec = recognition;
if (!rec) { resolve(); return; }
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(fallback);
rec.onresult = rec.onend = rec.onerror = null; // stop late events leaking
recognition = null;
resolve();
};
const fallback = setTimeout(finish, 2000); // never hang forever
onRecognitionEnd = finish;
try { rec.stop(); } catch (err) { finish(); }
});
}
Hai chi tiết phòng thủ giúp cách này an toàn: fallback 2 giây đảm bảo ứng dụng luôn tiếp tục ngay cả khi trình duyệt hoạt động không đúng, còn việc tháo các handler sẽ ngăn sự kiện đến muộn từ câu hỏi trước lọt sang câu hỏi tiếp theo. Chỉ sau khi Promise này hoàn tất, chúng ta mới dừng bộ ghi âm và đọc bản chép lời cuối cùng. Phần khó nằm ở timing, không phải ở bản thân API.
Gửi câu trả lời đến Django
Sau khi có cả blob âm thanh và bản chép lời, chúng ta gửi chúng cùng nhau. File âm thanh sẽ bị loại bỏ nếu không tồn tại hoặc vượt quá giới hạn 5MB của backend — riêng bản chép lời vẫn giữ được nội dung câu trả lời:
const formData = new FormData();
if (capturedBlob && capturedBlob.size > 0 && capturedBlob.size <= 5_000_000) {
formData.append('audio', capturedBlob, 'answer.webm');
}
formData.append('transcript', (finalTranscript + ' ' + interimTranscript).trim());
formData.append('question_id', q.id);
await interviewAPI.submitAnswer(interviewId, formData);
Ở phía server, view xác thực file âm thanh dựa trên nội dung thực tế — không dựa vào tên file, vì tên này luôn là answer.webm bất kể codec thực sự là gì — sau đó lưu bản ghi âm và bản chép lời vào câu hỏi:
class SubmitAnswerAPIView(APIView):
parser_classes = [MultiPartParser]
def post(self, request, *args, **kwargs):
audio = request.FILES.get('audio')
transcript = request.data.get('transcript')
if audio and audio.size > 5_000_000:
return Response({'err': 'Max file size is 5MB'}, status=400)
if audio and not is_valid_audio_file(audio):
return Response({'err': 'Audio format not supported!'}, status=400)
question = InterviewQuestion.objects.get(
id=request.data.get('question_id'), interview=interview,
)
if question.status == 2: # already answered — ignore duplicates
return Response({'msg': 'success'}, status=200)
question.record = audio
question.user_answer = transcript
question.status = 2 # completed
question.save(update_fields=['record', 'user_answer', 'status', 'updated_at'])
return Response(status=201)
Hãy chú ý đến điều kiện question.status == 2: bộ hẹn giờ có thể tự động gửi câu trả lời đúng lúc người dùng nhấp chuột, vì vậy endpoint phải có tính idempotent — lần gửi thứ hai cho một câu hỏi đã được trả lời sẽ được chấp nhận một cách im lặng thay vì xử lý trùng. Việc giữ lại âm thanh gốc cũng là chủ ý: tính năng chuyển giọng nói thành văn bản của trình duyệt tiện lợi nhưng chưa hoàn hảo, nên lưu bản ghi âm sẽ cho phép chúng ta chuyển đổi lại sau này bằng một dịch vụ chính xác hơn.
Những gì tính năng này mang lại
Khi đã có tính năng giọng nói, ứng dụng cuối cùng cũng làm đúng mục đích ban đầu: đọc từng câu hỏi thành tiếng, lắng nghe câu trả lời bằng giọng nói, hiển thị bản chép lời trực tiếp và lưu cả bản ghi âm lẫn văn bản. Đây mới là luyện phỏng vấn thực sự, chứ không chỉ là bài tập gõ câu trả lời.
Hầu như không phần nào trong công việc này thuộc về “happy path” — phần khó nằm ở timing: lấy quyền microphone đúng thứ tự, giữ lại kết quả tạm thời, khởi động lại khi có khoảng lặng và chờ qua race condition lúc dừng. Đó mới thực sự là phần khó của tính năng giọng nói trên trình duyệt, cũng là lý do rất nhiều bản demo chạy được một lần nhưng lại thất bại với người dùng thật. Nếu muốn bắt đầu từ phiên bản hoàn thiện và đã được kiểm thử trong môi trường production, bạn có thể tìm thấy toàn bộ mã nguồn trong starter kit. Nếu không, hãy xem phần 9 tiếp theo, nơi AI khép lại quy trình: chấm điểm từng câu trả lời và biến toàn bộ buổi phỏng vấn thành báo cáo cuối cùng.
CÂU HỎI THƯỜNG GẶP
Câu hỏi thường gặp
Vì sao phải lấy quyền microphone trước khi bắt đầu nhận dạng giọng nói?
Nếu SpeechRecognition đã chạy khi bạn gọi getUserMedia, Chrome sẽ âm thầm hủy phiên nhận dạng, khiến bản chép lời bị trống mà không có lỗi nào. Xử lý xong quyền microphone trước sẽ tránh hoàn toàn xung đột này.
Vì sao đôi khi bản chép lời giọng nói của tôi bị trống?
Nguyên nhân phổ biến nhất là bỏ qua các kết quả tạm thời. Chrome chỉ xác nhận kết quả sau một khoảng im lặng, vì vậy nếu người dùng nói liên tục cho đến lúc nhấn gửi mà không dừng lại ở cuối, đoạn nói cuối cùng sẽ không được xác nhận. Theo dõi và giữ lại các kết quả tạm thời sẽ khắc phục vấn đề này.
Vì sao SpeechRecognition tự dừng dù đang ở chế độ continuous?
Nó kết thúc phiên sau một khoảng im lặng, điều thường xảy ra khi người dùng dừng lại để suy nghĩ. Bạn có thể xử lý bằng cách khởi động lại tính năng nhận dạng trong handler onend khi câu trả lời vẫn còn hoạt động, đồng thời giữ lại những từ tạm thời từ phiên vừa kết thúc.
Vì sao phải ghi âm nếu trình duyệt đã chuyển giọng nói thành văn bản?
Tính năng chuyển giọng nói thành văn bản của trình duyệt tiện lợi nhưng chưa hoàn hảo và có thể khác nhau tùy thiết bị. Giữ lại bản ghi âm gốc cho phép bạn chuyển đổi lại sau này bằng một dịch vụ chính xác hơn, nên không bị phụ thuộc vào kết quả mà trình duyệt tạo ra tại thời điểm đó.
Vì sao phải kiểm tra nội dung file âm thanh thay vì phần mở rộng?
Tên file tải lên luôn là answer.webm bất kể codec thực tế là gì, nên phần mở rộng không cung cấp thông tin đáng tin cậy. Chỉ bằng cách xác thực các magic bytes thực tế của file, bạn mới có thể xác nhận đó thực sự là file âm thanh trước khi lưu trữ.