
Tiga Kata Kerja yang Menjaga Web Audio Tetap Hidup
Dua point release GeekBye, terpaut dua bulan dan di dua berkas berbeda, mengajari kode audio kami pelajaran yang sama dari ujung yang berlawanan: berhenti memperlakukan AudioContext milik browser sebagai barang sekali pakai. Satu rilis belajar untuk resume() sebuah context yang diam-diam disuspend macOS di tengah perekaman; yang lain belajar untuk suspend() alih-alih close() agar sesi beruntun berhenti menabrak plafon kira-kira enam context milik Chromium. Resume, suspend, close — itulah keseluruhan plotnya.
Sebagian besar waktu, sebuah Web Audio AudioContext berperilaku seperti objek biasa: kamu membuat satu, menyambungkan beberapa node, dan melupakannya. Dua rilis GeekBye adalah apa yang terjadi saat asumsi itu runtuh — dua kali, di dua bagian aplikasi yang berbeda, terpaut dua bulan. Tak satu pun direncanakan sebagai lanjutan dari yang lain. Tapi sandingkan keduanya dan mereka menceritakan satu kisah: AudioContext milik browser bukanlah objek sekali pakai. Ia adalah sumber daya langka, milik sistem operasi, dengan sebuah state machine sungguhan, dan tiga kata kerja dari state machine itu — resume, suspend, close — adalah keseluruhan plot dari kedua rilis.
GeekBye punya dua pipeline audio terpisah, itulah sebabnya pelajaran yang sama harus dipelajari dua kali. Yang satu adalah jalur perekaman video (ScreenRecordingService), yang membangun sebuah graf Web Audio untuk mencampur audio mik dan sistem ke dalam sebuah berkas video terekam. Yang lain adalah jalur transkripsi real-time (RealtimeAudioCaptureService), yang membangun grafnya sendiri untuk memasok speech-to-text. Masing-masing punya AudioContext-nya sendiri. Setiap rilis di bawah memperbaiki salah satunya.
v1.8.16: videonya sunyi, tapi transkripnya tidak
Bug pertama adalah otak-terbelah. Kamu sedang merekam sebuah rapat, kamu berpindah tab sejenak ke aplikasi lain, dan sesuatu yang halus terjadi: berkas video kehilangan mikrofonmu sementara transkrip langsung terus bergulir seolah tak ada yang berubah. Transkrip bersumpah kamu sedang berbicara. Videonya sunyi. Itu jenis bug yang membuat gila, karena dua hal yang seharusnya sepakat — "apakah mik mendengarmu" — mentah-mentah tak sepakat.
Commit yang memperbaikinya menamai penyebabnya dengan tepat:
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.
Beginilah mekanismenya. ScreenRecordingService.setupMonoMixingPipeline() membangun satu AudioContext, sebuah MediaStreamDestination yang menjadi track audio video, sebuah GainNode untuk bisu, dan sepasang ScriptProcessorNode (via createScriptProcessor) yang memompa frame audio ke logika VAD/STT. Saat macOS men-suspend context — yang bisa ia lakukan saat aplikasi kehilangan fokus selama perekaman layar — bagian-bagian graf yang digerakkan oleh clock perangkat keras audio berhenti: MediaStreamDestination berhenti menerima audio mik, jadi video terekam menjadi hening. Tapi callback onaudioprocess dari sebuah ScriptProcessorNode berjalan di thread utama JavaScript, dan thread itu terus berdetak. Jadi sisi STT terus memproduksi entri transkrip dari apa pun yang terakhir ia punya, dan kedua konsumen mikrofon yang sama pun berpisah arah.
Perbaikannya adalah menyadari suspensi itu dan membatalkannya:
audioContext.onstatechange = () => {
if (audioContext.state === 'suspended' && this.status === 'recording') {
logger.info('AudioContext suspended during recording, resuming')
audioContext.resume()
}
}
Itulah keseluruhan perubahan kebenarannya: pantau state context, dan jika ia turun ke suspended sementara kita masih seharusnya merekam, panggil resume(). Rilis ini juga menyambungkan listener onmute, onunmute, dan onended pada MediaStreamTrack mikrofon — bukan untuk mengubah perilaku, tapi supaya sebuah pembisuan tingkat-OS atau sebuah pemutusan perangkat muncul di log alih-alih menjadi penyebab keheningan yang tak terlihat. Saat seluruh kesulitan sebuah bug adalah "mengapa audionya berhenti," membuat berhentinya audio itu teramati adalah setengah dari perbaikannya.
Sebuah commit lanjutan mendarat beberapa menit kemudian untuk membereskan setelah listener-listener baru itu: cleanup() kini me-null-kan micTrack.onmute/onunmute/onended dan menyetel audioContext.onstatechange = null sebelum ia merobohkan graf. Catatan commit-nya bilang itu "matching RealtimeAudioCaptureService cleanup pattern" — layanan audio lain milik aplikasi. Pegang detail itu, karena dua bulan kemudian pola persis itulah yang harus ditulis ulang oleh rilis berikutnya.
v2.0.2: audio dan transkrip mati setelah sekitar enam sesi
Bug kedua tinggal di pipeline yang lain, RealtimeAudioCaptureService, dan ia punya bentuk yang menawan: semuanya bekerja sempurna, lalu setelah kamu memulai dan menghentikan perekaman beberapa kali dalam satu sesi aplikasi, audionya begitu saja... mati. Pertama audio sistem menjadi hening, lalu transkrip berhenti. Restart aplikasi dan ia baik lagi — sampai beberapa sesi berikutnya.
Catatan akar-penyebabnya layak dibaca utuh, karena ini adalah bug-di-balik-bug ala buku teks:
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'.
Jebakannya adalah kode lama sedang melakukan hal yang "bertanggung jawab." Setiap start() membuat sebuah AudioContext baru; setiap cleanup() memanggil close() padanya. Menutup sebuah sumber daya saat kamu selesai dengannya persis apa yang diajarkan untuk kamu lakukan. Bahkan ada komentar inline yang merasionalisasi pergolakan itu — bahwa memuat ulang modul AudioWorklet per context baru itu tak apa karena "a fresh context per start() means we always pay this cost once."
Tapi Chromium membatasi berapa banyak AudioContext berdukungan perangkat keras yang bisa dipegang satu halaman — kira-kira enam — dan, yang krusial, close() tak mengembalikan slotnya seketika saat kamu memanggilnya. Sebuah context tertutup tetap menempati slotnya sampai ia dikumpulkan oleh garbage collector, yang terjadi kapan pun mesin JS sempat menanganinya. Jadi kebijakan tutup-setiap-kali yang teliti justru persis hal yang menuntunmu ke plafon: enam siklus buat/tutup kemudian, keenam slot semuanya dipegang oleh mayat-mayat yang belum dikumpulkan, new AudioContext() ketujuh kembali dalam keadaan mati, dan setiap node yang kamu sambungkan padanya diam-diam tak melakukan apa-apa.
Perbaikannya adalah berhenti memperlakukan context sebagai barang sekali pakai. Buat sekali dan pakai ulang untuk seumur hidup layanan:
- Di
setupMonoMixingPipeline, konstruksi sebuahnew AudioContext(...)hanya jika belum ada atau yang ada sudahclosed. Jika ia ada tapisuspended,await audioContext.resume()alih-alih membuat yang baru. - Di
cleanup, gantiaudioContext.close()denganaudioContext.suspend()(dijaga agar tak pernah dipanggil pada context yang sudah tertutup). Context bertahan melewati sesi. - Karena context bertahan, modul
AudioWorkletyang termuat bertahan bersamanya — kode tak lagi mereset flag "worklet loaded"-nya saat cleanup, jadiaudioWorklet.addModule(...)dibayar sekali untuk seluruh umur layanan alih-alih setiap sesi.
Bagian-bagian per-sesi — node createMediaStreamSource untuk audio mik dan sistem, worklet mixer, dan passthrough hening yang menjaga tap loopback audio-sistem Chromium tetap hidup — masih dibangun dan dirobohkan setiap siklus. Hanya AudioContext itu sendiri yang bertahan, jadi paling banyak ada satu yang eksis pada waktu mana pun. Plafonnya tak terjangkau karena kamu tak pernah menumpuk context terhadapnya.
Satu catatan jujur yang commit itu buat tentang dirinya sendiri: ini diverifikasi dengan tangan — "manually verified: 8+ back-to-back sessions keep system audio audible and transcripts flowing (was dead at session 7)" — dan penulisnya terus terang bahwa siklus hidup Web Audio "isn't unit-testable in jsdom without a large new mock harness." Itu jenis kejujuran yang benar. Kegagalannya hanya muncul terhadap perangkat audio nyata melintasi sesi-sesi nyata; sebuah suite unit yang hijau takkan membuktikan apa-apa. "Kira-kira enam" dan "mati di sesi 7" adalah pengamatan tim tentang di mana batas Chromium menggigit, bukan sebuah konstanta terdokumentasi — kodenya sendiri berhati-hati dengan sebuah tilde, dan begitu pula seharusnya siapa pun yang mengulanginya.
Resume, suspend, close
Jajarkan kedua rilis dan simetrinya nyaris terlalu rapi. Keduanya tentang tiga metode yang sama pada jenis objek yang sama, didekati dari arah yang berlawanan:
- v1.8.16 belajar untuk
resume(). Context di-suspendeddari bawahnya oleh OS, dan perbaikannya adalah menangkap itu dan melanjutkan — jaga sebuah context yang seharusnya berjalan, tetap berjalan. - v2.0.2 belajar untuk
suspend()alih-alihclose(). Kode lama terlalu bernafsu menghancurkan context, dan perbaikannya adalah men-suspend dan memakainya ulang — jaga sebuah context yang mahal untuk dibuat ulang, tetap hidup.
Keduanya adalah dua paruh dari satu prinsip: berhenti memperlakukan AudioContext sebagai buangan. Satu paruh bilang jangan biarkan ia mati saat seharusnya tidak; yang lain bilang jangan bunuh ia saat tak perlu. Dan ada sedikit ironi di lini masa — cleanup v1.8.16 ditulis untuk "match RealtimeAudioCaptureService's cleanup pattern," lalu v2.0.2 menulis ulang persis pola itu di RealtimeAudioCaptureService, karena kebiasaan close()-setiap-kali dari pola itu ternyata adalah bug-nya. Kedua layanan terus belajar dari penanganan siklus-hidup-audio satu sama lain, satu perbaikan setiap kali.
Tiga hal yang diajarkan rilis-rilis ini kepada kami
- Sebuah
AudioContextpunya state machine — perlakukanstatesebagai penopang beban.running,suspended, danclosedbukan hal remeh. OS bisa memindahkan sebuah context di antaranya tanpa bertanya padamu, dan bagian-bagian graf yang berbeda bereaksi berbeda (sebuahScriptProcessorNodedi thread utama terus jalan; tujuan berclock-perangkat-keras tidak). Jika kamu tak memantauonstatechange, kamu memercayai bahwa context tetap di tempat kamu meninggalkannya — dan di desktop nyata, di bawah perubahan fokus dan perekaman layar, ia takkan begitu. close()bukanlahfree(). Intuisi bahwa menutup sebuah sumber daya dengan sigap selalu benar persis yang menyebabkan bug kedua. Saat sumber dayanya adalah sebuah slot dalam sebuah pool keras yang dipaksakan browser yang hanya direklamasi pada waktu GC, menutup secara agresif adalah cara kamu menguras pool itu. Kadang siklus hidup yang benar adalah buat-sekali-suspend-sering, bukan buat-dan-hancurkan-per-pemakaian.- Beberapa bug hanya terjangkau lewat pintu perangkat keras nyata dan waktu nyata. "Mati setelah sekitar enam sesi" dan "menjadi hening saat kamu pindah tab" tak bisa ditangkap oleh unit test yang me-mock perangkat audio — dan tim mengatakannya terus terang alih-alih menutupinya dengan sebuah test yang lulus palsu. Namai batas-batas test harness-mu; sebuah bug yang butuh delapan sesi nyata untuk muncul layak mendapat "diverifikasi manual" yang jujur, bukan tanda centang hijau yang tak berarti apa-apa.
Untuk bab sebelumnya, membedakan panggilan dari aplikasi yang terbuka (v1.8.15–v1.8.19); dan untuk keseluruhan alurnya, anatomi merilis software sampai sempurna.