Steven
Steven10 मिनट पढ़ें

तीन क्रियाएँ जो Web Audio को ज़िंदा रखती हैं

GeekBye की दो point releases, दो महीने के फ़ासले पर और दो अलग files में, हमारे audio code को उलटे छोरों से एक ही सबक़ सिखा गईं: browser के AudioContext को इस्तेमाल-कर-फेंको जैसा मानना बंद करो। एक release ने एक ऐसे context को resume() करना सीखा जिसे macOS ने record के बीचोंबीच चुपचाप suspend कर दिया था; दूसरी ने close() के बजाय suspend() करना सीखा ताकि पीठ-दर-पीठ चलती sessions Chromium की लगभग-छह-context वाली छत से टकराना बंद कर दें। resume, suspend, close — यही पूरी कहानी है।

इंजीनियरिंग
Audio
डेस्कटॉप
GeekBye रिलीज़
तीन क्रियाएँ जो Web Audio को ज़िंदा रखती हैं

अधिकतर समय, एक Web Audio AudioContext एक सादे object जैसा बर्ताव करता है: आप एक बनाते हैं, कुछ nodes जोड़ते हैं, और उसे भूल जाते हैं। दो GeekBye releases वही हैं जो तब हुआ जब वह मान्यता टूटी — दो बार, app के दो अलग हिस्सों में, दो महीने के फ़ासले पर। किसी को भी दूसरे के follow-up के रूप में नहीं सोचा गया था। पर दोनों को अग़ल-बग़ल रखो और वे एक ही कहानी सुनाती हैं: browser का AudioContext एक इस्तेमाल-कर-फेंको object नहीं है। यह एक असली state machine वाला, दुर्लभ, operating-system-स्वामित्व वाला संसाधन है, और उस state machine की तीन क्रियाएँ — resume, suspend, close — दोनों releases की पूरी कहानी हैं।

GeekBye की दो अलग audio pipelines हैं, यही वजह है कि वही सबक़ दो बार सीखना पड़ा। एक है video-recording रास्ता (ScreenRecordingService), जो mic और system audio को एक record हो रही video file में मिलाने के लिए एक Web Audio graph बनाता है। दूसरा है real-time transcription रास्ता (RealtimeAudioCaptureService), जो speech-to-text को खिलाने के लिए अपना ख़ुद का graph बनाता है। हर एक के पास अपना AudioContext है। नीचे की हर release इनमें से एक को ठीक करती है।

v1.8.16: video ख़ामोश हो गई, पर transcript नहीं

पहला bug एक विभाजित-मस्तिष्क है। आप एक meeting record कर रहे हैं, आप एक पल के लिए किसी दूसरे app पर tab बदलते हैं, और कुछ सूक्ष्म घटता है: video file आपका microphone खो देती है जबकि live transcript ऐसे स्क्रॉल होता रहता है मानो कुछ बदला ही न हो। transcript क़सम खाता है कि आप बोल रहे थे। video ख़ामोश है। यह एक झल्ला देने वाली क़िस्म का bug है क्योंकि जिन दो चीज़ों को सहमत होना चाहिए — «क्या mic ने आपको सुना» — वे सिरे से असहमत हैं।

जो 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 जो video का audio track बनता है, mute के लिए एक GainNode, और (createScriptProcessor के ज़रिए) ScriptProcessorNode की एक जोड़ी बनाता है जो audio frames को VAD/STT logic की ओर पंप करती है। जब macOS context को suspend करता है — जो वह screen recording के दौरान app के focus खोने पर कर सकता है — graph के वे हिस्से रुक जाते हैं जो audio hardware clock से चलते हैं: MediaStreamDestination mic audio पाना बंद कर देता है, तो record हो रही video शांत हो जाती है। पर एक ScriptProcessorNode का onaudioprocess callback JavaScript main thread पर चलता है, और वह thread टिकटिक करता रहता है। तो STT पक्ष जो कुछ उसके पास आख़िरी बार था उससे transcript entries बनाता रहता है, और एक ही microphone के दो उपभोक्ता अलग-अलग हो जाते हैं।

fix है उस suspend को ताड़ना और उसे पलट देना:

audioContext.onstatechange = () => {
  if (audioContext.state === 'suspended' && this.status === 'recording') {
    logger.info('AudioContext suspended during recording, resuming')
    audioContext.resume()
  }
}

यही पूरा शुद्धता-परिवर्तन है: context के state को ताको, और अगर वह suspended पर गिर जाए जबकि हमें अभी भी record करते रहना चाहिए, तो resume() बुलाओ। यह release microphone के MediaStreamTrack पर onmute, onunmute, और onended listeners भी जोड़ती है — बर्ताव बदलने के लिए नहीं, बल्कि इसलिए कि एक OS-स्तरीय mute या एक device का disconnect ख़ामोशी का एक अदृश्य कारण होने के बजाय logs में दिखे। जब एक bug की सारी मुश्किल «audio क्यों रुक गया» हो, तब audio के रुकने को अवलोकनीय बना देना fix का आधा हिस्सा है।

कुछ मिनट बाद एक follow-up commit उतरी ताकि उन नए listeners के बाद सफ़ाई की जा सके: cleanup() अब graph को गिराने से पहले micTrack.onmute/onunmute/onended को null कर देता है और audioContext.onstatechange = null सेट करता है। commit की टिप्पणी कहती है कि यह «matching RealtimeAudioCaptureService cleanup pattern» है — app की दूसरी audio service। इस ब्योरे को थामे रखिए, क्योंकि दो महीने बाद ठीक वही pattern वह चीज़ है जिसे अगली release को दोबारा लिखना पड़ा।

v2.0.2: audio और transcripts लगभग छह sessions के बाद मर जाते हैं

दूसरा bug दूसरी pipeline, RealtimeAudioCaptureService, में रहता है, और उसका एक अद्भुत आकार है: सब कुछ बेहतरीन चलता है, और फिर एक ही app session में जब आप record कुछ बार शुरू और बंद कर चुके होते हैं, audio बस… मर जाता है। पहले system audio ख़ामोश होता है, फिर transcript रुकता है। app को restart करो तो फिर ठीक — जब तक कि एक और कुछ sessions न बीत जाएँ।

मूल-कारण की टिप्पणी पूरी पढ़ने लायक़ है, क्योंकि यह एक पाठ्यपुस्तकीय bug-के-पीछे-का-bug है:

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'.

जाल यह है कि पुराना code वही «ज़िम्मेदार» काम कर रहा था। हर start() एक नया AudioContext बनाता; हर cleanup() उस पर close() बुलाता। एक संसाधन को इस्तेमाल के बाद बंद कर देना ठीक वही है जो आपको करना सिखाया जाता है। इस उथल-पुथल को जायज़ ठहराती एक inline टिप्पणी तक थी — कि हर नए context पर AudioWorklet module को दोबारा load करना ठीक है क्योंकि «हर start() पर एक नया context मतलब हम यह ख़र्च हमेशा एक ही बार चुकाते हैं»।

पर Chromium सीमित करता है कि एक अकेला page कितने hardware-आधारित AudioContext थाम सकता है — लगभग छह — और, निर्णायक रूप से, close() उस पल जगह वापस नहीं थमा देता जिस पल आप उसे बुलाते हैं। एक बंद हुआ context अपनी जगह तब तक घेरे रहता है जब तक वह garbage-collect न हो जाए, जो तब होता है जब भी JS engine को फ़ुर्सत मिले। तो एक सतर्क हर-बार-बंद-करो नीति ठीक वही चीज़ है जो आपको छत में ले चलती है: छह बनाओ/बंद-करो चक्रों बाद, छहों जगहें अभी-तक-न-इकट्ठे हुए मुर्दों के क़ब्ज़े में हैं, सातवाँ new AudioContext() मुर्दा लौटता है, और उससे आप जो भी node जोड़ते हैं वह ख़ामोशी से कुछ नहीं करता।

fix है context को इस्तेमाल-कर-फेंको मानना बंद करना। उसे एक ही बार बनाओ और service की आयु-भर उसे दोबारा इस्तेमाल करो:

  • setupMonoMixingPipeline में, एक new AudioContext(...) सिर्फ़ तभी बनाओ जब अभी कोई है ही नहीं या मौजूदा वाला closed है। अगर वह मौजूद है पर suspended है, तो एक नया बनाने के बजाय await audioContext.resume() करो।
  • cleanup में, audioContext.close() को audioContext.suspend() से बदलो (इस तरह guard किया कि यह कभी किसी पहले-से-बंद context पर न बुलाया जाए)। context session से बच निकलता है।
  • चूँकि context बचता है, loaded AudioWorklet module भी उसके साथ बचता है — code अब cleanup पर अपना «worklet loaded» flag रीसेट नहीं करता, तो audioWorklet.addModule(...) हर session के बजाय पूरी service-आयु के लिए एक ही बार चुकाया जाता है।

प्रति-session वाले टुकड़े — mic और system audio के लिए createMediaStreamSource nodes, mixer worklet, और वह silent passthrough जो Chromium के system-audio loopback tap को ज़िंदा रखता है — अब भी हर चक्र बनाए और गिराए जाते हैं। सिर्फ़ AudioContext ख़ुद क़ायम रहता है, तो किसी भी समय ज़्यादा से ज़्यादा एक ही अस्तित्व में होता है। छत तक पहुँचा नहीं जा सकता क्योंकि आप contexts को उसके ख़िलाफ़ कभी ढेर करते ही नहीं।

एक ईमानदार टिप्पणी जो commit अपने बारे में करती है: इसे हाथ से सत्यापित किया गया — «manually verified: 8+ back-to-back sessions system audio सुनाई देती रखती हैं और transcripts बहते रखती हैं (session 7 पर मुर्दा थी)» — और लेखक स्पष्ट है कि Web Audio lifecycle «एक बड़े नए mock harness के बिना jsdom में unit-testable नहीं है»। यह सही क़िस्म की ईमानदारी है। यह नाकामी सिर्फ़ असली sessions के आर-पार एक असली audio device के ख़िलाफ़ प्रकट होती है; एक हरा unit suite कुछ भी साबित न करता। «लगभग छह» और «session 7 पर मुर्दा» team के अवलोकन हैं कि Chromium की सीमा ने कहाँ काटा, कोई प्रलेखित स्थिरांक नहीं — code ख़ुद एक tilde से बचाव करता है, और जो कोई इसे दोहराए उसे भी वैसा ही करना चाहिए।

resume, suspend, close

दोनों releases को क़तार में लगाओ तो सममिति क़रीब-क़रीब हद से ज़्यादा सुथरी है। दोनों एक ही क़िस्म के object पर उन्हीं तीन methods के बारे में हैं, उलटी दिशाओं से पहुँचे गए:

  • v1.8.16 ने resume() करना सीखा। context को OS ने पैरों तले से suspended कर दिया, और fix है उसे पकड़कर resume करना — एक ऐसे context को जिसे चलते रहना चाहिए, चलता रखना।
  • v2.0.2 ने close() के बजाय suspend() करना सीखा। पुराना code context को नष्ट करने को बहुत उतावला था, और fix है उसे suspend करके दोबारा इस्तेमाल करना — एक ऐसे context को जिसे दोबारा बनाना महँगा है, ज़िंदा रखना।

ये एक ही सिद्धांत के दो आधे हैं: AudioContext को फेंकने की चीज़ मानना बंद करो। एक आधा कहता है इसे तब मरने मत दो जब इसे नहीं मरना चाहिए; दूसरा कहता है इसे तब मत मारो जब तुम्हें ज़रूरत नहीं। और timeline में एक छोटी विडंबना है — v1.8.16 की cleanup «RealtimeAudioCaptureService की cleanup pattern से match» करने के लिए लिखी गई थी, और फिर v2.0.2 ने RealtimeAudioCaptureService में ठीक वही pattern दोबारा लिखा, क्योंकि उस pattern की हर-बार-close() वाली आदत ही bug निकली। दोनों services एक-दूसरे की audio-lifecycle संभाल से सीखती रहती हैं, एक समय में एक fix।

तीन चीज़ें जो इन releases ने सिखाईं

  1. एक AudioContext के पास एक state machine है — state को भार-वहन करने वाला मानो। running, suspended, और closed तुच्छ जानकारियाँ नहीं हैं। OS एक context को तुमसे पूछे बिना इनके बीच सरका सकता है, और तुम्हारे graph के अलग-अलग हिस्से अलग-अलग प्रतिक्रिया देते हैं (main thread पर एक ScriptProcessorNode चलता रहता है; hardware-clock वाला destination नहीं)। अगर तुम onstatechange नहीं ताकते, तो तुम भरोसा कर रहे हो कि context वहीं रहेगा जहाँ तुमने छोड़ा — और एक असली desktop पर, focus बदलावों और screen recording के तहत, वह नहीं रहेगा।
  2. close(), free() नहीं है। यह अंतर्ज्ञान कि एक संसाधन को फ़ौरन बंद कर देना हमेशा सही है, ठीक वही है जिसने दूसरा bug पैदा किया। जब संसाधन एक सख़्त, browser-थोपे pool में एक जगह हो जो सिर्फ़ GC के समय पर वापस मिलती है, तब आक्रामक ढंग से बंद करना ही वह तरीक़ा है जिससे तुम pool को चुका देते हो। कभी-कभी सही lifecycle है एक-बार-बनाओ-अक्सर-suspend-करो, न कि हर-उपयोग-पर-बनाओ-और-नष्ट-करो।
  3. कुछ bugs सिर्फ़ असली hardware और असली समय के दरवाज़े से ही पहुँच में आते हैं। «लगभग छह sessions के बाद मर जाता है» और «जब तुम tab बदलते हो तब ख़ामोश हो जाता है» एक ऐसे unit test से नहीं पकड़े जा सकते जो audio device को mock करता है — और team ने एक झूठे-पास होते test से इसे ढाँपने के बजाय साफ़-साफ़ यही कहा। अपने test harness की सीमाओं को नाम दो; एक ऐसा bug जिसे प्रकट होने में आठ असली sessions चाहिए, एक ईमानदार «manually verified» का हक़दार है, न कि एक हरे निशान का जो कुछ भी मायने नहीं रखता।

पिछले अध्याय के लिए, देखें एक call को एक खुले हुए app से अलग पहचानना (v1.8.15–v1.8.19); और पूरे चाप के लिए, सॉफ़्टवेयर को परिपूर्णता तक पहुँचाकर भेजने की शारीर-रचना

संबंधित लेख

ख़ामोशी भार-वहन कर रही थी
Steven
Steven9 मिनट पढ़ें

ख़ामोशी भार-वहन कर रही थी

GeekBye v1 की आख़िरी दो releases एक ही असहज सच्चाई के बारे में हैं: एक असली network पर real-time transcription लॉसलेस नहीं है, और ईमानदार चाल है यह दिखावा बंद करना कि वह है। v1.8.20 ने एक reconnect के दौरान हर audio chunk को गिराने से पहले उसकी एक प्रति disk पर रखी, और transcript के अंतरालों को ज़ोर से चिह्नित करना शुरू किया। v1.9.0 ने bandwidth बचाने के लिए silence भेजना बंद किया — और पाया कि वह silence ही वह ठीक-ठीक संकेत था जिससे transcriber जानता था कि एक वाक्य ख़त्म हो चुका है। चीज़ें फेंक देने की क़ीमत के बारे में दो releases।

इंजीनियरिंग
Audio
विश्वसनीयता
एक call को एक खुले हुए app से अलग पहचानना
Steven
Steven10 मिनट पढ़ें

एक call को एक खुले हुए app से अलग पहचानना

GeekBye यह ताड़ सकता है कि आप एक video meeting में शामिल हो गए हैं और एक क्लिक में उसे record करने की पेशकश करता है। पता चलता है कि detection ही आसान वाला आधा था — एक Swift binary जो हर दस सेकंड window titles पढ़ती है। मुश्किल आधा है सटीकता: जब Zoom बस खुला हो तब न भड़कना, एक ऐसी meeting के लिए न टोकना जिसे आप पहले से record कर रहे हैं, और उस call में microphone को mute न करना जिसमें आप असल में हैं। तीन releases, और हर एक एक ऐसा guard है जिसे ख़ुद को न हराना सीखना पड़ा।

इंजीनियरिंग
macOS
डेस्कटॉप
अपलोड के रास्ते से backend को हटा देना
Steven
Steven9 मिनट पढ़ें

अपलोड के रास्ते से backend को हटा देना

GeekBye आपकी स्क्रीन record करता है और video को आपके Google Drive में save कर देता है। पहला version हर recording को वहाँ तक पहुँचने के रास्ते में GeekBye के अपने servers से होकर भेजता था; एक release बाद, file आपकी मशीन से सीधे Drive चली जाती थी, और backend को महज़ एक pointer थामे रखने तक घटा दिया गया। दिलचस्प हिस्सा यह है कि वह «सीधा, resumable» version असल में कितना कम code रखता है — क्योंकि resumability एक proxy लिखने से नहीं, बल्कि उसे मिटाने से आई।

इंजीनियरिंग
आर्किटेक्चर
डेस्कटॉप