Steven
Steven11 phút đọc

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.

Kỹ thuật
Audio
Desktop
Bản phát hành GeekBye
Ba Động Từ Giữ Cho Web Audio Sống

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ột new 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 đang suspended, thì await audioContext.resume() thay vì làm một cái mới.
  • Trong cleanup, thay audioContext.close() bằng audioContext.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ên audioWorklet.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ị OS suspended mấ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

  1. Một AudioContext có một state machine — hãy coi state là chịu tải. running, suspended, và closed khô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ột ScriptProcessorNode trê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 canh onstatechange, 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.
  2. close() không phải free(). 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.
  3. 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.

Bài Viết Liên Quan

Sự Im Lặng Vốn Chịu Tải
Steven
Steven10 phút đọc

Sự Im Lặng Vốn Chịu Tải

Hai bản phát hành cuối cùng của GeekBye v1 đều xoay quanh cùng một sự thật khó chịu: phiên âm thời gian thực trên một mạng thật không phải là không mất mát, và nước đi trung thực là thôi giả vờ rằng nó như vậy. v1.8.20 giữ một bản sao của mỗi audio chunk trên đĩa trước khi bỏ nó trong một lần kết nối lại, và bắt đầu đánh dấu các khoảng trống trong transcript ra thành tiếng. v1.9.0 thôi gửi im lặng để tiết kiệm bandwidth — rồi phát hiện ra im lặng chính là tín hiệu chính xác mà bộ phiên âm dùng để biết một câu đã kết thúc. Hai bản phát hành về cái giá của việc vứt đồ đi.

Kỹ thuật
Audio
Độ tin cậy
Phân Biệt Một Cuộc Gọi Với Một App Đang Mở
Steven
Steven11 phút đọc

Phân Biệt Một Cuộc Gọi Với Một App Đang Mở

GeekBye có thể nhận ra bạn vừa vào một cuộc họp video và đề nghị ghi lại nó chỉ bằng một cú nhấp. Việc phát hiện hóa ra là nửa dễ — một Swift binary đọc tiêu đề cửa sổ mỗi mười giây. Nửa khó là độ chính xác: không phát hỏa khi Zoom chỉ đang mở, không nhắc cho một cuộc họp bạn đã đang ghi, và không tắt tiếng cái mic trong cuộc gọi bạn thực sự đang ngồi. Ba bản phát hành, và mỗi bản là một guard phải học cách không tự đánh bại chính mình.

Kỹ thuật
macOS
Desktop
Gỡ Backend Ra Khỏi Đường Đi Của Upload
Steven
Steven10 phút đọc

Gỡ Backend Ra Khỏi Đường Đi Của Upload

GeekBye quay lại màn hình của bạn và lưu video vào Google Drive của bạn. Phiên bản đầu tiên đẩy mọi bản ghi qua chính máy chủ của GeekBye trên đường tới đó; một bản phát hành sau, tập tin đi thẳng từ máy của bạn tới Drive, và backend bị giáng xuống thành thứ chỉ giữ một con trỏ. Phần thú vị là cái phiên bản «trực tiếp, có thể tiếp tục» ấy thực ra chứa ít code đến mức nào — vì khả năng tiếp tục đến từ việc xóa đi một proxy, chứ không phải từ việc viết ra một cái.

Kỹ thuật
Kiến trúc
Desktop