
Ba Động Từ Giữ Cho Web Audio Sống
Hai bản point release của GeekBye, cách nhau hai tháng và nằm trong hai file khác nhau, đã dạy code âm thanh của chúng tôi cùng một bài học từ hai đầu đối nghịch: thôi coi AudioContext của browser như một thứ dùng-rồi-bỏ. Một bản học được cách resume() một context mà macOS đã lặng lẽ suspend giữa lúc ghi; bản kia học được cách suspend() thay vì close() để những session nối tiếp nhau thôi đâm sầm vào cái trần khoảng-sáu-context của Chromium. resume, suspend, close — đó là toàn bộ cốt truyện.
Phần lớn thời gian, một Web Audio AudioContext cư xử như một object trơn tru: bạn dựng một cái, đấu vài node, rồi quên nó đi. Hai bản phát hành GeekBye là những gì đã xảy ra khi cái giả định ấy vỡ — hai lần, ở hai phần khác nhau của app, cách nhau hai tháng. Chẳng bản nào được lên kế hoạch như một phần tiếp nối của bản kia. Nhưng đặt chúng cạnh nhau và chúng kể cùng một câu chuyện: AudioContext của browser không phải là một object dùng-rồi-bỏ. Nó là một tài nguyên khan hiếm, do hệ điều hành sở hữu, với một state machine thật, và ba động từ của cái state machine đó — resume, suspend, close — là toàn bộ cốt truyện của cả hai bản phát hành.
GeekBye có hai audio pipeline tách biệt, đó là lý do vì sao cùng một bài học phải được học hai lần. Một là đường ghi video (ScreenRecordingService), dựng một Web Audio graph để trộn mic và system audio vào một file video được ghi. Cái kia là đường phiên âm thời gian thực (RealtimeAudioCaptureService), dựng graph riêng của nó để nuôi speech-to-text. Mỗi cái có một AudioContext của riêng mình. Mỗi bản phát hành dưới đây sửa một trong hai.
v1.8.16: video hóa câm, nhưng transcript thì không
Bug đầu tiên là một kiểu não-tách-đôi. Bạn đang ghi một cuộc họp, bạn chuyển tab sang một app khác trong chốc lát, và một điều tinh vi xảy ra: file video mất cái mic của bạn trong khi transcript trực tiếp vẫn cuộn tiếp như thể chẳng có gì đổi. Transcript thề rằng bạn đang nói. Video thì im lặng. Đó là một loại bug phát điên vì hai thứ đáng lẽ phải khớp nhau — «mic có nghe thấy bạn không» — lại thẳng thừng bất đồng.
Cái commit sửa nó gọi tên nguyên nhân một cách chính xác:
AudioContext can be suspended by macOS when the app loses focus during screen recording. This silences the mic in the video file while the ScriptProcessorNode (STT path) keeps running on the JS main thread, causing a split where transcripts continue but the video has no mic.
Cơ chế là thế này. ScreenRecordingService.setupMonoMixingPipeline() dựng một AudioContext, một MediaStreamDestination sẽ trở thành audio track của video, một GainNode để tắt tiếng, và một cặp ScriptProcessorNode (qua createScriptProcessor) bơm các audio frame tới logic VAD/STT. Khi macOS suspend cái context — điều nó có thể làm khi app mất focus giữa một lần ghi màn hình — các phần của graph được dẫn động bởi clock của audio hardware dừng lại: MediaStreamDestination thôi nhận audio của mic, nên video được ghi trở nên im ắng. Nhưng callback onaudioprocess của một ScriptProcessorNode chạy trên JavaScript main thread, và cái thread đó vẫn tích tắc tiếp. Nên phía STT vẫn tiếp tục sản ra các transcript entry từ bất cứ thứ gì nó có gần nhất, và hai kẻ tiêu thụ cùng một cái microphone rẽ ra hai ngả.
Bản sửa là để ý thấy cú suspend đó và đảo ngược nó:
audioContext.onstatechange = () => {
if (audioContext.state === 'suspended' && this.status === 'recording') {
logger.info('AudioContext suspended during recording, resuming')
audioContext.resume()
}
}
Đó là toàn bộ thay đổi về tính đúng đắn: canh chừng state của context, và nếu nó rơi xuống suspended trong khi ta lẽ ra vẫn đang ghi, thì gọi resume(). Bản phát hành này cũng đấu các listener onmute, onunmute, và onended lên MediaStreamTrack của microphone — không phải để đổi hành vi, mà để một cú tắt tiếng cấp OS hay một lần rớt thiết bị hiện lên trong log thay vì là một nguyên nhân vô hình của sự im lặng. Khi toàn bộ cái khó của một bug là «tại sao audio lại dừng», thì làm cho việc audio dừng trở nên quan sát được chính là một nửa của bản sửa.
Một commit tiếp theo đáp xuống vài phút sau để dọn dẹp sau các listener mới đó: cleanup() giờ gán null cho micTrack.onmute/onunmute/onended và đặt audioContext.onstatechange = null trước khi nó tháo dỡ graph. Ghi chú commit nói rằng nó đang «matching RealtimeAudioCaptureService cleanup pattern» — cái audio service kia của app. Hãy giữ lấy chi tiết đó, vì hai tháng sau chính cái pattern y hệt ấy là thứ mà bản phát hành kế tiếp phải viết lại.
v2.0.2: audio và transcript chết sau khoảng sáu session
Bug thứ hai sống trong pipeline kia, RealtimeAudioCaptureService, và nó có một hình dạng tuyệt vời: mọi thứ chạy hoàn hảo, rồi sau khi bạn bắt đầu và dừng ghi vài ba lần trong một phiên app, audio cứ thế… chết. Đầu tiên system audio hóa câm, rồi transcript dừng. Khởi động lại app thì lại ổn — cho tới thêm vài ba session nữa.
Ghi chú nguyên nhân gốc rễ đáng đọc trọn vẹn, vì nó là một cái bug-đằng-sau-bug kiểu sách giáo khoa:
Root cause: setupMonoMixingPipeline created a new AudioContext on every start() and cleanup() closed it. Chromium hard-caps hardware AudioContexts at ~6 per page and closed-but-not-yet-GC'd contexts keep their slot, so ~6 back-to-back sessions exhaust the cap. The next context is dead → the silent-monitor passthrough can't play (no system audio) AND the mixer worklet can't capture (no transcript) — the reported 'after 6+ sessions audio dies, then transcript dies'.
Cái bẫy là code cũ đang làm cái điều «có trách nhiệm». Mỗi start() tạo một AudioContext mới toanh; mỗi cleanup() gọi close() lên nó. Đóng một tài nguyên khi bạn dùng xong với nó chính xác là điều bạn được dạy để làm. Thậm chí còn có một comment inline biện hộ cho sự xoay vòng đó — rằng nạp lại module AudioWorklet cho mỗi context mới thì không sao vì «một context mới cho mỗi start() nghĩa là ta luôn trả chi phí này đúng một lần».
Nhưng Chromium giới hạn số AudioContext nền-hardware mà một page đơn có thể giữ — khoảng sáu — và, mấu chốt là, close() không trao lại cái chỗ ngay khoảnh khắc bạn gọi nó. Một context đã đóng vẫn tiếp tục chiếm chỗ của nó cho tới khi nó được garbage-collect, điều xảy ra bất cứ khi nào JS engine rảnh tay tới lượt. Nên một chính sách đóng-mọi-lần tỉ mỉ chính xác là thứ dẫn bạn đi thẳng vào cái trần: sáu chu kỳ tạo/đóng sau đó, sáu cái chỗ đều bị giữ bởi những xác chết chưa được thu, cái new AudioContext() thứ bảy trả về đã chết, và mọi node bạn gắn vào nó lặng lẽ chẳng làm gì cả.
Bản sửa là thôi coi context như đồ dùng-rồi-bỏ. Tạo nó một lần và tái dùng nó suốt vòng đời của service:
- Trong
setupMonoMixingPipeline, chỉ dựng mộtnew AudioContext(...)nếu chưa có cái nào hoặc cái đang có làclosed. Nếu nó tồn tại nhưng đangsuspended, thìawait audioContext.resume()thay vì làm một cái mới. - Trong
cleanup, thayaudioContext.close()bằngaudioContext.suspend()(được canh để không bao giờ bị gọi lên một context đã đóng). Context sống sót qua session. - Vì context sống sót, module
AudioWorkletđã nạp cũng sống sót cùng nó — code không còn reset cái flag «worklet loaded» của nó khi cleanup, nênaudioWorklet.addModule(...)được trả một lần cho cả vòng đời của service thay vì mỗi session.
Các mảnh theo-từng-session — những node createMediaStreamSource cho mic và system audio, mixer worklet, và cái silent passthrough giữ cho cái tap system-audio loopback của Chromium còn sống — vẫn được dựng và tháo mỗi chu kỳ. Chỉ bản thân AudioContext là tồn tại lâu, nên có nhiều nhất một cái hiện hữu tại bất kỳ lúc nào. Cái trần trở nên không thể chạm tới vì bạn không bao giờ chồng các context lên để đâm vào nó.
Một ghi chú thành thật mà commit tự nói về mình: cái này được kiểm bằng tay — «đã kiểm thủ công: 8+ session nối tiếp nhau giữ system audio còn nghe được và transcript còn chảy (trước đó đã chết ở session 7)» — và tác giả thẳng thắn rằng vòng đời Web Audio «không unit-test được trong jsdom nếu không có một mock harness mới đồ sộ». Đó là đúng cái loại thành thật cần có. Sự cố chỉ hiện ra khi đối mặt với một thiết bị âm thanh thật qua các session thật; một bộ unit test xanh lè hẳn đã chẳng chứng minh được gì. «Khoảng sáu» và «chết ở session 7» là những quan sát của nhóm về chỗ mà cái trần của Chromium cắn xuống, không phải một hằng số có tài liệu — chính code cũng rào lại bằng một dấu ngã, và ai lặp lại nó cũng nên vậy.
resume, suspend, close
Xếp hai bản phát hành cạnh nhau và sự đối xứng gần như quá gọn ghẽ. Cả hai đều về cùng ba method trên cùng một loại object, tiếp cận từ hai hướng đối nghịch:
- v1.8.16 học được cách
resume(). Cái context bị OSsuspendedmất từ dưới chân, và bản sửa là bắt lấy điều đó rồi resume — giữ cho một context đáng lẽ phải đang chạy, chạy tiếp. - v2.0.2 học được cách
suspend()thay vìclose(). Code cũ quá hăm hở phá hủy context, và bản sửa là suspend rồi tái dùng nó — giữ cho một context đắt đỏ để tái tạo, còn sống.
Chúng là hai nửa của một nguyên tắc duy nhất: thôi coi AudioContext như một thứ vứt đi. Một nửa nói đừng để nó chết khi nó không nên; nửa kia nói đừng giết nó khi bạn không phải làm vậy. Và có một mỉa mai nhỏ trong dòng thời gian — cleanup của v1.8.16 được viết để «khớp cleanup pattern của RealtimeAudioCaptureService», rồi v2.0.2 viết lại đúng cái pattern đó trong RealtimeAudioCaptureService, vì cái thói quen close()-mọi-lần của pattern ấy hóa ra chính là con bug. Hai service cứ học từ cách xử lý vòng đời âm thanh của nhau, mỗi lần một bản sửa.
Ba điều những bản phát hành này đã dạy
- Một
AudioContextcó một state machine — hãy coistatelà chịu tải.running,suspended, vàclosedkhông phải chuyện vặt. OS có thể chuyển một context giữa chúng mà không hỏi bạn, và các phần khác nhau của graph phản ứng khác nhau (mộtScriptProcessorNodetrên main thread cứ chạy tiếp; cái destination được dẫn bằng clock hardware thì không). Nếu bạn không canhonstatechange, bạn đang tin rằng context ở yên chỗ bạn để nó — và trên một desktop thật, dưới những lần đổi focus và ghi màn hình, nó sẽ không. close()không phảifree(). Cái trực giác rằng đóng một tài nguyên cho mau luôn luôn đúng chính là thứ đã gây ra con bug thứ hai. Khi tài nguyên là một chỗ trong một pool cứng do browser áp đặt mà chỉ được thu hồi vào lúc GC, thì đóng một cách hung hăng chính là cách bạn làm cạn cái pool. Đôi khi vòng đời đúng là tạo-một-lần-suspend-thường-xuyên, không phải tạo-rồi-hủy-mỗi-lần-dùng.- Có những bug chỉ chạm tới được qua cánh cửa của hardware thật và thời gian thật. «Chết sau khoảng sáu session» và «hóa câm khi bạn chuyển tab đi» không thể bị bắt bởi một unit test mock cái thiết bị âm thanh — và nhóm đã nói thẳng thế thay vì che đậy bằng một test giả-vờ-đậu. Hãy gọi tên các giới hạn của test harness của bạn; một con bug cần tám session thật mới hiện ra thì xứng đáng một câu «đã kiểm thủ công» thành thật, chứ không phải một dấu tick xanh chẳng có nghĩa gì.
Về chương trước, xem Phân Biệt Một Cuộc Gọi Với Một App Đang Mở (v1.8.15–v1.8.19); và cho toàn bộ vòng cung, xem giải phẫu việc xuất xưởng phần mềm đến độ hoàn hảo.