
สามกริยาที่ทำให้ Web Audio ยังมีชีวิต
GeekBye สอง point release ห่างกันสองเดือนและอยู่คนละไฟล์ สอนโค้ดเสียงของเราด้วยบทเรียนเดียวกันจากปลายสองด้านตรงข้าม: เลิกมอง AudioContext ของ browser ว่าเป็นของใช้แล้วทิ้ง release หนึ่งเรียนรู้ที่จะ resume() context ที่ macOS แอบ suspend ไปกลางการอัด; อีกอันเรียนรู้ที่จะ suspend() แทน close() เพื่อให้ session ที่ต่อกันเป็นพรืดเลิกพุ่งชนเพดานราวหก context ของ Chromium resume, suspend, close — นั่นคือทั้งเรื่อง
โดยมากแล้ว Web Audio AudioContext ทำตัวเหมือน object ธรรมดา: คุณสร้างขึ้นมาหนึ่งอัน, ต่อ node สองสามตัว, แล้วก็ลืมมันไป สอง release ของ GeekBye คือสิ่งที่เกิดขึ้นเมื่อสมมติฐานนั้นแตกสลาย — สองครั้ง, ในสองส่วนต่างกันของแอป, ห่างกันสองเดือน ไม่มีอันไหนถูกวางแผนให้เป็น follow-up ของอีกอัน แต่วางมันเคียงกันแล้วมันเล่าเรื่องเดียว: AudioContext ของ browser ไม่ใช่ object ใช้แล้วทิ้ง มันคือทรัพยากรหายากที่ระบบปฏิบัติการเป็นเจ้าของ พร้อม state machine จริง ๆ, และสามกริยาของ state machine นั้น — resume, suspend, close — คือเนื้อเรื่องทั้งหมดของทั้งสอง release
GeekBye มี audio pipeline สองสายแยกกัน, ซึ่งเป็นเหตุว่าทำไมบทเรียนเดียวกันจึงต้องถูกเรียนสองรอบ สายหนึ่งคือการอัด video (ScreenRecordingService), ที่สร้าง Web Audio graph เพื่อผสมไมค์กับ system audio ลงในไฟล์ video ที่ถูกอัด อีกสายคือการถอดเสียงแบบเรียลไทม์ (RealtimeAudioCaptureService), ที่สร้าง graph ของตัวเองเพื่อป้อน speech-to-text แต่ละสายมี AudioContext ของตัวเอง แต่ละ release ข้างล่างแก้ไปหนึ่งสาย
v1.8.16: video เงียบไป แต่ transcript ไม่
บั๊กแรกคือสมองแยกซีก คุณกำลังอัดการประชุม, คุณสลับแท็บไปแอปอื่นชั่วครู่, แล้วมีบางอย่างละเอียดอ่อนเกิดขึ้น: ไฟล์ video เสียไมค์ของคุณไป ในขณะที่ transcript สด ๆ ยังเลื่อนต่อไปราวกับไม่มีอะไรเปลี่ยน transcript สาบานว่าคุณกำลังพูดอยู่ video เงียบ นั่นคือบั๊กแบบที่ทำให้คลุ้มคลั่ง เพราะสองสิ่งที่ควรตรงกัน — «ไมค์ได้ยินคุณไหม» — กลับขัดกันดื้อ ๆ
commit ที่แก้มันตั้งชื่อสาเหตุอย่างแม่นยำ:
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.
กลไกเป็นอย่างนี้ ScreenRecordingService.setupMonoMixingPipeline() สร้าง AudioContext หนึ่งตัว, MediaStreamDestination ที่จะกลายเป็น audio track ของ video, GainNode สำหรับ mute, และ ScriptProcessorNode คู่หนึ่ง (ผ่าน createScriptProcessor) ที่ปั๊ม audio frame ไปยัง logic ของ VAD/STT เมื่อ macOS suspend context — ซึ่งมันทำได้เมื่อแอปเสียโฟกัสระหว่างการอัดหน้าจอ — ส่วนของ graph ที่ขับด้วย clock ของ audio hardware ก็หยุด: MediaStreamDestination หยุดรับ audio ของไมค์, ดังนั้น video ที่ถูกอัดจึงเงียบไป แต่ callback onaudioprocess ของ ScriptProcessorNode รันบน JavaScript main thread, และ thread นั้นยังเดินติ๊ก ๆ ต่อไป ดังนั้นฝั่ง STT จึงผลิต transcript entry จากสิ่งที่มันมีล่าสุดต่อไปเรื่อย ๆ, และผู้บริโภคสองรายของไมค์ตัวเดียวกันก็แยกทางกัน
วิธีแก้คือสังเกตการ suspend นั้นและย้อนมันกลับ:
audioContext.onstatechange = () => {
if (audioContext.state === 'suspended' && this.status === 'recording') {
logger.info('AudioContext suspended during recording, resuming')
audioContext.resume()
}
}
นั่นคือการเปลี่ยนเชิงความถูกต้องทั้งหมด: เฝ้าดู state ของ context, และถ้ามันตกไปที่ suspended ในขณะที่เรายังควรจะอัดอยู่, ก็เรียก resume() release นี้ยังต่อ listener onmute, onunmute, และ onended บน MediaStreamTrack ของไมค์ — ไม่ใช่เพื่อเปลี่ยนพฤติกรรม, แต่เพื่อให้การ mute ระดับ OS หรือการหลุดของอุปกรณ์โผล่ขึ้นใน log แทนที่จะเป็นสาเหตุที่มองไม่เห็นของความเงียบ เมื่อความยากทั้งหมดของบั๊กคือ «ทำไม audio ถึงหยุด», การทำให้การหยุดของ audio สังเกตได้ ก็คือครึ่งหนึ่งของการแก้
commit ตามมาอีกอันลงจอดสองสามนาทีให้หลังเพื่อเก็บกวาดหลัง listener ใหม่เหล่านั้น: ตอนนี้ cleanup() เซ็ต micTrack.onmute/onunmute/onended เป็น null และตั้ง audioContext.onstatechange = null ก่อนจะรื้อ graph ทิ้ง โน้ตของ commit บอกว่ามัน «matching RealtimeAudioCaptureService cleanup pattern» — audio service อีกตัว ของแอป จำรายละเอียดนี้ไว้, เพราะสองเดือนถัดมา pattern อันเป๊ะ ๆ นั้นแหละคือสิ่งที่ release ถัดไปต้องเขียนใหม่
v2.0.2: audio และ transcript ตายหลังราวหก session
บั๊กที่สองอยู่ในอีก pipeline, RealtimeAudioCaptureService, และมันมีรูปทรงที่งดงาม: ทุกอย่างทำงานสมบูรณ์แบบ, แล้วหลังจากคุณเริ่มและหยุดอัดสักไม่กี่หนใน app session เดียว, audio ก็แค่… ตาย ก่อนอื่น system audio เงียบไป, แล้ว transcript ก็หยุด รีสตาร์ทแอปก็ดีอีกครั้ง — จนกว่าจะอีกไม่กี่ session
โน้ตสาเหตุรากเหง้าคุ้มค่าที่จะอ่านเต็ม ๆ, เพราะมันคือบั๊ก-เบื้องหลัง-บั๊กแบบในตำรา:
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'.
กับดักคือ โค้ดเก่ากำลังทำสิ่งที่ «รับผิดชอบ» ทุก start() สร้าง AudioContext ใหม่; ทุก cleanup() เรียก close() กับมัน การปิดทรัพยากรเมื่อคุณใช้เสร็จคือสิ่งที่เขาสอนให้ทำเป๊ะ ๆ มีถึงขั้น comment inline ที่หาเหตุผลให้การวุ่นวายไปมานี้ — ว่าการโหลด AudioWorklet module ใหม่ต่อ context ใหม่ไม่เป็นไร เพราะ «context ใหม่ต่อ start() แปลว่าเราจ่ายต้นทุนนี้แค่ครั้งเดียวเสมอ»
แต่ Chromium จำกัดว่า page เดียวถือ AudioContext ที่ backed ด้วย hardware ได้กี่ตัว — ราวหก — และที่สำคัญ, close() ไม่ได้ยื่นที่คืนให้ในวินาทีที่คุณเรียกมัน context ที่ปิดแล้วยังกินที่ของมันต่อไปจนกว่ามันจะถูก garbage-collect, ซึ่งเกิดขึ้นเมื่อ JS engine ว่างมาจัดการ ดังนั้นนโยบายปิด-ทุกครั้งอย่างพิถีพิถัน_ก็คือ_สิ่งที่พาคุณเดินเข้าเพดานพอดี: หกรอบของ สร้าง/ปิด ต่อมา, ที่ทั้งหกถูกยึดไว้ด้วยศพที่ยังไม่ถูกเก็บ, new AudioContext() ตัวที่เจ็ดกลับมาตายแล้ว, และทุก node ที่คุณต่อกับมันก็เงียบ ๆ ไม่ทำอะไรเลย
วิธีแก้คือเลิกมอง context ว่าใช้แล้วทิ้ง สร้างมันครั้งเดียวแล้วใช้ซ้ำตลอดอายุของ service:
- ใน
setupMonoMixingPipeline, สร้างnew AudioContext(...)ก็ต่อเมื่อยังไม่มีสักตัว หรือตัวที่มีอยู่เป็นclosedเท่านั้น ถ้ามันมีอยู่แต่เป็นsuspended, ให้await audioContext.resume()แทนการสร้างตัวใหม่ - ใน
cleanup, แทนaudioContext.close()ด้วยaudioContext.suspend()(มี guard เพื่อไม่ให้ถูกเรียกกับ context ที่ปิดไปแล้วเด็ดขาด) context รอดข้าม session ไปได้ - เพราะ context รอด,
AudioWorkletmodule ที่โหลดแล้วก็รอดไปกับมัน — โค้ดไม่รีเซ็ต flag «worklet loaded» ของมันตอน cleanup อีกต่อไป, ดังนั้นaudioWorklet.addModule(...)จ่ายครั้งเดียวตลอดอายุของ service แทนที่จะจ่ายทุก session
ชิ้นส่วนต่อ-session — node createMediaStreamSource สำหรับไมค์และ system audio, mixer worklet, และ silent passthrough ที่คอยเลี้ยง tap ของ system-audio loopback ของ Chromium ให้ยังมีชีวิต — ยังถูกสร้างและรื้อทุกรอบ มีเพียง AudioContext เองที่คงอยู่, ดังนั้นจึงมีอยู่มากที่สุดแค่หนึ่งเดียวในเวลาใด ๆ เพดานเอื้อมไม่ถึง เพราะคุณไม่เคยกอง context ซ้อนใส่มัน
โน้ตซื่อสัตย์หนึ่งอันที่ commit พูดถึงตัวเอง: อันนี้ถูกตรวจด้วยมือ — «ตรวจด้วยมือแล้ว: 8+ session ที่ต่อกันเป็นพรืด system audio ยังได้ยินและ transcript ยังไหลอยู่ (เคยตายที่ session 7)» — และผู้เขียนตรงไปตรงมาว่า lifecycle ของ Web Audio «unit-test ไม่ได้ใน jsdom หากไม่มี mock harness ใหม่ก้อนใหญ่» นั่นคือความซื่อสัตย์แบบที่ถูกต้อง ความล้มเหลวโผล่เฉพาะเมื่อเจอกับอุปกรณ์เสียงจริงข้ามหลาย session จริง; unit suite สีเขียวคงพิสูจน์อะไรไม่ได้เลย «ราวหก» และ «ตายที่ session 7» คือสิ่งที่ทีมสังเกตว่าเพดานของ Chromium กัดตรงไหน, ไม่ใช่ค่าคงที่ที่มีเอกสาร — โค้ดเองก็กันตัวไว้ด้วยตัวหนอน, และใครก็ตามที่พูดต่อก็ควรทำเช่นกัน
resume, suspend, close
เรียงสอง release ให้ตรงกันแล้วความสมมาตรแทบจะเนี้ยบเกินไป ทั้งคู่ว่าด้วยสามเมธอดเดียวกันบน object ชนิดเดียวกัน, เข้าหาจากทิศตรงข้าม:
- v1.8.16 เรียนรู้ที่จะ
resume()context ถูก OSsuspendedไปจากใต้เท้า, และวิธีแก้คือคว้ามันไว้แล้ว resume — เก็บ context ที่ควรกำลังทำงานอยู่ ให้ทำงานต่อ - v2.0.2 เรียนรู้ที่จะ
suspend()แทนclose()โค้ดเก่ากระตือรือร้นเกินไปที่จะทำลาย context, และวิธีแก้คือ suspend แล้วใช้ซ้ำ — เก็บ context ที่สร้างใหม่แล้วแพง ให้ยังมีชีวิต
พวกมันคือสองครึ่งของหลักการเดียว: เลิกมอง AudioContext ว่าเป็นของทิ้ง ครึ่งหนึ่งบอกว่าอย่าปล่อยให้มันตายตอนที่มันไม่ควร; อีกครึ่งบอกว่าอย่าฆ่ามันตอนที่คุณไม่จำเป็น และมีการประชดเล็ก ๆ ในไทม์ไลน์ — cleanup ของ v1.8.16 ถูกเขียนให้ «match cleanup pattern ของ RealtimeAudioCaptureService», แล้ว v2.0.2 ก็เขียน pattern อันนั้นเป๊ะ ๆ ใหม่ใน RealtimeAudioCaptureService, เพราะนิสัยปิด-close()-ทุกครั้งของ pattern นั้นกลายเป็นตัวบั๊ก สอง service เรียนรู้จากการจัดการ audio lifecycle ของกันและกันเรื่อยไป, ทีละหนึ่งการแก้
สามสิ่งที่ release เหล่านี้สอน
AudioContextมี state machine — ปฏิบัติต่อstateในฐานะที่รับน้ำหนักrunning,suspended, และclosedไม่ใช่เกร็ดจุกจิก OS ย้าย context ไปมาระหว่างพวกมันได้โดยไม่ถามคุณ, และส่วนต่างกันของ graph ก็ตอบสนองต่างกัน (ScriptProcessorNodeบน main thread ยังไปต่อ; destination ที่ขับด้วย clock ของ hardware ไม่ไป) ถ้าคุณไม่เฝ้าonstatechange, คุณกำลังเชื่อว่า context อยู่ที่ที่คุณทิ้งมันไว้ — และบนเดสก์ท็อปจริง, ภายใต้การเปลี่ยนโฟกัสและการอัดหน้าจอ, มันจะไม่อยู่close()ไม่ใช่free()สัญชาตญาณที่ว่าการปิดทรัพยากรทันทีถูกต้องเสมอ คือสิ่งที่ก่อบั๊กที่สองพอดี เมื่อทรัพยากรคือที่นั่งใน pool ที่ browser บังคับไว้แข็ง ๆ และได้คืนเฉพาะตอน GC, การปิดอย่างดุดันคือวิธีที่คุณใช้ pool จนหมด บางที lifecycle ที่ถูกคือ สร้างครั้งเดียว-suspend-บ่อย ๆ, ไม่ใช่ สร้างแล้วทำลายทุกครั้งที่ใช้- บางบั๊กเอื้อมถึงได้เฉพาะผ่านประตูของ hardware จริงและเวลาจริง «ตายหลังราวหก session» และ «เงียบไปตอนคุณสลับแท็บออก» จับไม่ได้ด้วย unit test ที่ mock อุปกรณ์เสียง — และทีมก็พูดตรง ๆ แทนที่จะกลบมันด้วย test ที่แกล้งผ่าน ตั้งชื่อให้ขีดจำกัดของ test harness ของคุณ; บั๊กที่ต้องใช้แปด session จริงกว่าจะโผล่ สมควรได้ «ตรวจด้วยมือแล้ว» อย่างซื่อสัตย์, ไม่ใช่เครื่องหมายถูกสีเขียวที่ไม่มีความหมายอะไร
สำหรับบทก่อนหน้า ดูแยกการโทรออกจากแอปที่แค่เปิดค้างไว้ (v1.8.15–v1.8.19); และสำหรับภาพรวมทั้งเส้นเรื่อง ดูกายวิภาคของการส่งมอบซอฟต์แวร์ให้สมบูรณ์แบบ