Steven
Steven5 นาทีที่อ่าน

สามกริยาที่ทำให้ Web Audio ยังมีชีวิต

GeekBye สอง point release ห่างกันสองเดือนและอยู่คนละไฟล์ สอนโค้ดเสียงของเราด้วยบทเรียนเดียวกันจากปลายสองด้านตรงข้าม: เลิกมอง AudioContext ของ browser ว่าเป็นของใช้แล้วทิ้ง release หนึ่งเรียนรู้ที่จะ resume() context ที่ macOS แอบ suspend ไปกลางการอัด; อีกอันเรียนรู้ที่จะ suspend() แทน close() เพื่อให้ session ที่ต่อกันเป็นพรืดเลิกพุ่งชนเพดานราวหก context ของ Chromium resume, suspend, close — นั่นคือทั้งเรื่อง

วิศวกรรม
Audio
Desktop
GeekBye รีลีส
สามกริยาที่ทำให้ Web Audio ยังมีชีวิต

โดยมากแล้ว 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 รอด, AudioWorklet module ที่โหลดแล้วก็รอดไปกับมัน — โค้ดไม่รีเซ็ต 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 ถูก OS suspended ไปจากใต้เท้า, และวิธีแก้คือคว้ามันไว้แล้ว 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 เหล่านี้สอน

  1. AudioContext มี state machine — ปฏิบัติต่อ state ในฐานะที่รับน้ำหนัก running, suspended, และ closed ไม่ใช่เกร็ดจุกจิก OS ย้าย context ไปมาระหว่างพวกมันได้โดยไม่ถามคุณ, และส่วนต่างกันของ graph ก็ตอบสนองต่างกัน (ScriptProcessorNode บน main thread ยังไปต่อ; destination ที่ขับด้วย clock ของ hardware ไม่ไป) ถ้าคุณไม่เฝ้า onstatechange, คุณกำลังเชื่อว่า context อยู่ที่ที่คุณทิ้งมันไว้ — และบนเดสก์ท็อปจริง, ภายใต้การเปลี่ยนโฟกัสและการอัดหน้าจอ, มันจะไม่อยู่
  2. close() ไม่ใช่ free() สัญชาตญาณที่ว่าการปิดทรัพยากรทันทีถูกต้องเสมอ คือสิ่งที่ก่อบั๊กที่สองพอดี เมื่อทรัพยากรคือที่นั่งใน pool ที่ browser บังคับไว้แข็ง ๆ และได้คืนเฉพาะตอน GC, การปิดอย่างดุดันคือวิธีที่คุณใช้ pool จนหมด บางที lifecycle ที่ถูกคือ สร้างครั้งเดียว-suspend-บ่อย ๆ, ไม่ใช่ สร้างแล้วทำลายทุกครั้งที่ใช้
  3. บางบั๊กเอื้อมถึงได้เฉพาะผ่านประตูของ hardware จริงและเวลาจริง «ตายหลังราวหก session» และ «เงียบไปตอนคุณสลับแท็บออก» จับไม่ได้ด้วย unit test ที่ mock อุปกรณ์เสียง — และทีมก็พูดตรง ๆ แทนที่จะกลบมันด้วย test ที่แกล้งผ่าน ตั้งชื่อให้ขีดจำกัดของ test harness ของคุณ; บั๊กที่ต้องใช้แปด session จริงกว่าจะโผล่ สมควรได้ «ตรวจด้วยมือแล้ว» อย่างซื่อสัตย์, ไม่ใช่เครื่องหมายถูกสีเขียวที่ไม่มีความหมายอะไร

สำหรับบทก่อนหน้า ดูแยกการโทรออกจากแอปที่แค่เปิดค้างไว้ (v1.8.15–v1.8.19); และสำหรับภาพรวมทั้งเส้นเรื่อง ดูกายวิภาคของการส่งมอบซอฟต์แวร์ให้สมบูรณ์แบบ

บทความที่เกี่ยวข้อง

ความเงียบคือสิ่งที่รับน้ำหนัก
Steven
Steven3 นาทีที่อ่าน

ความเงียบคือสิ่งที่รับน้ำหนัก

สอง release สุดท้ายของ GeekBye v1 ว่าด้วยความจริงอันน่าอึดอัดข้อเดียวกัน: การถอดเสียงแบบเรียลไทม์บนเครือข่ายจริงไม่ได้ไร้การสูญเสีย และการเดินหมากที่ซื่อสัตย์คือเลิกแกล้งทำเป็นว่ามันไร้การสูญเสีย v1.8.20 เก็บสำเนาของ audio chunk ทุกก้อนไว้บนดิสก์ก่อนจะทิ้งมันระหว่างการเชื่อมต่อใหม่ และเริ่มทำเครื่องหมายช่องว่างใน transcript ออกมาดัง ๆ v1.9.0 เลิกส่งความเงียบเพื่อประหยัด bandwidth — แล้วก็ค้นพบว่าความเงียบนั่นแหละคือสัญญาณเป๊ะ ๆ ที่เครื่องถอดเสียงใช้รู้ว่าประโยคจบแล้ว สอง release ว่าด้วยราคาของการโยนของทิ้ง

วิศวกรรม
Audio
ความน่าเชื่อถือ
แยกการโทรออกจากแอปที่แค่เปิดค้างไว้
Steven
Steven4 นาทีที่อ่าน

แยกการโทรออกจากแอปที่แค่เปิดค้างไว้

GeekBye สังเกตได้ว่าคุณเข้าร่วมการประชุมวิดีโอแล้ว และเสนอบันทึกมันให้ด้วยคลิกเดียว การตรวจจับกลายเป็นครึ่งที่ง่าย — Swift binary ที่อ่านชื่อหน้าต่างทุกสิบวินาที ครึ่งที่ยากคือความแม่นยำ: ไม่ยิงตอน Zoom แค่เปิดค้างอยู่, ไม่เตือนสำหรับการประชุมที่คุณกำลังบันทึกอยู่แล้ว, และไม่ mute ไมค์ในการโทรที่คุณอยู่จริง ๆ สามรีลีส และแต่ละอันคือ guard ที่ต้องเรียนรู้ที่จะไม่เอาชนะตัวเอง

วิศวกรรม
macOS
Desktop
เอา backend ออกจากเส้นทางอัปโหลด
Steven
Steven5 นาทีที่อ่าน

เอา backend ออกจากเส้นทางอัปโหลด

GeekBye บันทึกหน้าจอของคุณและเซฟวิดีโอลง Google Drive ของคุณ เวอร์ชันแรกส่งทุกการบันทึกผ่านเซิร์ฟเวอร์ของ GeekBye เองระหว่างทางไปที่นั่น; อีกหนึ่งรีลีสถัดมา ไฟล์ก็วิ่งตรงจากเครื่องของคุณไป Drive และ backend ถูกลดขั้นเหลือเพียงถือ pointer หนึ่งตัว ส่วนที่น่าสนใจคือเวอร์ชัน «ตรง, resumable» นั้นจริง ๆ แล้วบรรจุโค้ดน้อยแค่ไหน — เพราะความ resumable มาจากการลบ proxy ทิ้ง ไม่ใช่จากการเขียนมันขึ้นมา

วิศวกรรม
สถาปัตยกรรม
Desktop