Steven
Steven9 λεπτά ανάγνωσης

Τα τρία ρήματα που κρατούν το Web Audio ζωντανό

Δύο point releases του GeekBye, με δύο μήνες απόσταση και σε δύο διαφορετικά αρχεία, δίδαξαν στον κώδικα ήχου μας το ίδιο μάθημα από αντίθετα άκρα: σταμάτα να αντιμετωπίζεις το AudioContext του browser ως αναλώσιμο. Μία έκδοση έμαθε να κάνει resume() ένα context που το macOS είχε σιωπηλά suspend στη μέση της καταγραφής· η άλλη έμαθε να κάνει suspend() αντί για close(), ώστε οι διαδοχικές sessions να σταματήσουν να χτυπούν στο ταβάνι των περίπου έξι context του Chromium. Resume, suspend, close — αυτή είναι όλη η πλοκή.

Μηχανική
Audio
Desktop
Εκδόσεις GeekBye
Τα τρία ρήματα που κρατούν το Web Audio ζωντανό

Τις περισσότερες φορές, ένα Web Audio AudioContext συμπεριφέρεται σαν ένα απλό αντικείμενο: κατασκευάζεις ένα, συνδέεις μερικά nodes, και το ξεχνάς. Δύο εκδόσεις του GeekBye είναι αυτό που συνέβη όταν εκείνη η υπόθεση έσπασε — δύο φορές, σε δύο διαφορετικά μέρη της εφαρμογής, με δύο μήνες απόσταση. Καμία δεν σχεδιάστηκε ως συνέχεια της άλλης. Αλλά βάλε τες δίπλα δίπλα και λένε μία ιστορία: το AudioContext του browser δεν είναι ένα αναλώσιμο αντικείμενο. Είναι ένας σπάνιος πόρος, ιδιοκτησία του λειτουργικού συστήματος, με μια πραγματική μηχανή καταστάσεων, και τα τρία ρήματα εκείνης της μηχανής καταστάσεων — resume, suspend, close — είναι όλη η πλοκή και των δύο εκδόσεων.

Το GeekBye έχει δύο ξεχωριστά audio pipelines, γι αυτό το ίδιο μάθημα έπρεπε να μαθευτεί δύο φορές. Το ένα είναι η διαδρομή καταγραφής βίντεο (ScreenRecordingService), που χτίζει ένα Web Audio graph για να μείξει το μικρόφωνο και τον ήχο συστήματος σε ένα καταγεγραμμένο αρχείο βίντεο. Το άλλο είναι η διαδρομή μεταγραφής σε πραγματικό χρόνο (RealtimeAudioCaptureService), που χτίζει το δικό του graph για να τροφοδοτήσει το speech-to-text. Το καθένα έχει το δικό του AudioContext. Κάθε έκδοση παρακάτω διορθώνει ένα από αυτά.

v1.8.16: το βίντεο βούβανε, αλλά η μεταγραφή όχι

Το πρώτο bug είναι ένας διχασμένος εγκέφαλος. Καταγράφεις μια σύσκεψη, αλλάζεις για μια στιγμή σε άλλη εφαρμογή, και συμβαίνει κάτι διακριτικό: το αρχείο βίντεο χάνει το μικρόφωνό σου ενώ η ζωντανή μεταγραφή συνεχίζει να κυλά σαν να μην άλλαξε τίποτα. Η μεταγραφή ορκίζεται ότι μιλούσες. Το βίντεο είναι βουβό. Είναι ένα εξοργιστικό είδος bug, επειδή τα δύο πράγματα που θα έπρεπε να συμφωνούν — „σε άκουσε το μικρόφωνο" — διαφωνούν κατηγορηματικά.

Το 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 του βίντεο, ένα GainNode για σίγαση, και ένα ζεύγος ScriptProcessorNode (μέσω createScriptProcessor) που αντλούν audio frames προς τη λογική VAD/STT. Όταν το macOS κάνει suspend το context — πράγμα που μπορεί να κάνει όταν η εφαρμογή χάνει την εστίαση κατά τη διάρκεια μιας καταγραφής οθόνης — τα μέρη του graph που οδηγούνται από το ρολόι του audio hardware σταματούν: το MediaStreamDestination σταματά να λαμβάνει ήχο μικροφώνου, οπότε το καταγεγραμμένο βίντεο βουβαίνει. Αλλά το callback onaudioprocess ενός ScriptProcessorNode τρέχει στο κύριο νήμα JavaScript, και εκείνο το νήμα συνεχίζει να χτυπά. Έτσι η πλευρά STT συνεχίζει να παράγει εγγραφές μεταγραφής από ό,τι είχε τελευταία, και οι δύο καταναλωτές του ίδιου μικροφώνου αποκλίνουν.

Η διόρθωση είναι να παρατηρήσεις το suspend και να το αναιρέσεις:

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

Αυτή είναι όλη η αλλαγή ορθότητας: παρακολούθησε το state του context, και αν πέσει σε suspended ενώ ακόμη υποτίθεται ότι καταγράφουμε, κάλεσε resume(). Η έκδοση συνδέει επίσης listeners onmute, onunmute, και onended στο MediaStreamTrack του μικροφώνου — όχι για να αλλάξει τη συμπεριφορά, αλλά ώστε μια σίγαση σε επίπεδο λειτουργικού ή μια αποσύνδεση συσκευής να εμφανίζεται στα logs αντί να είναι μια αόρατη αιτία σιωπής. Όταν όλη η δυσκολία ενός bug είναι „γιατί σταμάτησε ο ήχος", το να κάνεις το σταμάτημα του ήχου παρατηρήσιμο είναι η μισή διόρθωση.

Ένα επόμενο commit προσγειώθηκε λίγα λεπτά αργότερα για να καθαρίσει μετά από εκείνους τους νέους listeners: το cleanup() τώρα μηδενίζει τα micTrack.onmute/onunmute/onended και θέτει audioContext.onstatechange = null πριν γκρεμίσει το graph. Η σημείωση του commit λέει ότι „ταιριάζει με το cleanup pattern του RealtimeAudioCaptureService" — την άλλη audio υπηρεσία της εφαρμογής. Κράτα αυτή τη λεπτομέρεια, επειδή δύο μήνες αργότερα ακριβώς αυτό το pattern είναι που η επόμενη έκδοση έπρεπε να ξαναγράψει.

v2.0.2: ο ήχος και οι μεταγραφές πεθαίνουν μετά από περίπου έξι sessions

Το δεύτερο bug ζει στο άλλο pipeline, το RealtimeAudioCaptureService, και έχει ένα υπέροχο σχήμα: όλα δουλεύουν τέλεια, και μετά, αφού έχεις ξεκινήσει και σταματήσει την καταγραφή μια χούφτα φορές σε μία session της εφαρμογής, ο ήχος απλώς... πεθαίνει. Πρώτα ο ήχος συστήματος βουβαίνει, μετά η μεταγραφή σταματά. Επανεκκίνησε την εφαρμογή και είναι πάλι εντάξει — μέχρι μια ακόμη χούφτα 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'.

Η παγίδα είναι ότι ο παλιός κώδικας έκανε το „υπεύθυνο" πράγμα. Κάθε start() δημιουργούσε ένα φρέσκο AudioContext· κάθε cleanup() καλούσε close() πάνω του. Το να κλείνεις έναν πόρο όταν τελειώσεις μαζί του είναι ακριβώς αυτό που σε διδάσκουν να κάνεις. Υπήρχε ακόμη και ένα inline σχόλιο που δικαιολογούσε τη σπατάλη — ότι η επαναφόρτωση του module AudioWorklet ανά φρέσκο context ήταν εντάξει επειδή „ένα φρέσκο context ανά start() σημαίνει ότι πάντα πληρώνουμε αυτό το κόστος μία φορά".

Αλλά το Chromium περιορίζει πόσα hardware-backed AudioContext μπορεί να κρατήσει μία σελίδα — περίπου έξι — και, κρίσιμα, το close() δεν επιστρέφει τη θέση τη στιγμή που το καλείς. Ένα κλειστό context συνεχίζει να καταλαμβάνει τη θέση του μέχρι να γίνει garbage-collected, πράγμα που συμβαίνει όποτε ευκαιρήσει η μηχανή JS. Έτσι μια σχολαστική πολιτική κλείσε-κάθε-φορά είναι ακριβώς αυτό που σε οδηγεί στο ταβάνι: έξι κύκλους δημιουργίας/κλεισίματος αργότερα, οι έξι θέσεις κρατιούνται όλες από μη-ακόμη-συλλεγμένα πτώματα, το έβδομο new AudioContext() επιστρέφει νεκρό, και κάθε node που του συνδέεις σιωπηλά δεν κάνει τίποτα.

Η διόρθωση είναι να σταματήσεις να αντιμετωπίζεις το context ως αναλώσιμο. Δημιούργησέ το μία φορά και επαναχρησιμοποίησέ το για τη διάρκεια ζωής της υπηρεσίας:

  • Στο setupMonoMixingPipeline, κατασκεύασε ένα new AudioContext(...) μόνο αν δεν υπάρχει ήδη ένα ή το υπάρχον είναι closed. Αν υπάρχει αλλά είναι suspended, κάνε await audioContext.resume() αντί να φτιάξεις ένα νέο.
  • Στο cleanup, αντικατέστησε το audioContext.close() με audioContext.suspend() (με προστασία ώστε να μην καλείται ποτέ σε ένα ήδη κλειστό context). Το context επιβιώνει της session.
  • Επειδή το context επιβιώνει, το φορτωμένο module AudioWorklet επιβιώνει μαζί του — ο κώδικας δεν μηδενίζει πια το flag του „worklet loaded" στο cleanup, οπότε το audioWorklet.addModule(...) πληρώνεται μία φορά για όλη τη διάρκεια ζωής της υπηρεσίας αντί κάθε session.

Τα ανά-session κομμάτια — τα nodes createMediaStreamSource για μικρόφωνο και ήχο συστήματος, το mixer worklet, και το βουβό passthrough που κρατά ζωντανό το loopback tap του ήχου συστήματος του Chromium — εξακολουθούν να χτίζονται και να γκρεμίζονται κάθε κύκλο. Μόνο το ίδιο το AudioContext παραμένει, οπότε υπάρχει το πολύ ένα ανά πάσα στιγμή. Το ταβάνι είναι απρόσιτο επειδή ποτέ δεν στοιβάζεις contexts πάνω του.

Μια έντιμη σημείωση που κάνει το commit για τον εαυτό του: αυτό επαληθεύτηκε με το χέρι — „επαληθεύτηκε χειροκίνητα: 8+ διαδοχικές sessions κρατούν τον ήχο συστήματος ακουστό και τις μεταγραφές να ρέουν (ήταν νεκρό στη session 7)" — και ο συγγραφέας είναι ειλικρινής ότι ο κύκλος ζωής του Web Audio „δεν είναι unit-testable στο jsdom χωρίς ένα μεγάλο νέο mock harness". Αυτό είναι το σωστό είδος ειλικρίνειας. Η αποτυχία εμφανίζεται μόνο απέναντι σε μια πραγματική συσκευή ήχου σε πραγματικές sessions· μια πράσινη σουίτα unit δεν θα είχε αποδείξει τίποτα. Το „περίπου έξι" και το „νεκρό στη session 7" είναι οι παρατηρήσεις της ομάδας για το πού δάγκωσε το όριο του Chromium, όχι μια τεκμηριωμένη σταθερά — ο ίδιος ο κώδικας διστάζει με μια περισπωμένη, και έτσι θα έπρεπε και όποιος το επαναλαμβάνει.

Resume, suspend, close

Παράταξε τις δύο εκδόσεις και η συμμετρία είναι σχεδόν υπερβολικά καθαρή. Και οι δύο αφορούν τις ίδιες τρεις μεθόδους στο ίδιο είδος αντικειμένου, προσεγγισμένες από αντίθετες κατευθύνσεις:

  • Η v1.8.16 έμαθε να κάνει resume(). Το context έγινε suspended κάτω από τα πόδια του από το λειτουργικό, και η διόρθωση είναι να το πιάσεις αυτό και να κάνεις resume — κράτα ένα context που υποτίθεται ότι τρέχει, να τρέχει.
  • Η v2.0.2 έμαθε να κάνει suspend() αντί για close(). Ο παλιός κώδικας ήταν υπερβολικά πρόθυμος να καταστρέψει το context, και η διόρθωση είναι να το κάνεις suspend και να το επαναχρησιμοποιήσεις — κράτα ζωντανό ένα context που είναι ακριβό να ξαναδημιουργηθεί.

Είναι τα δύο μισά μιας ενιαίας αρχής: σταμάτα να αντιμετωπίζεις το AudioContext ως πετάμενο. Το ένα μισό λέει μην το αφήσεις να πεθάνει όταν δεν πρέπει· το άλλο λέει μην το σκοτώσεις όταν δεν χρειάζεται. Και υπάρχει μια μικρή ειρωνεία στο χρονολόγιο — το cleanup της v1.8.16 γράφτηκε για να „ταιριάζει με το cleanup pattern του RealtimeAudioCaptureService", και μετά η v2.0.2 ξαναέγραψε ακριβώς αυτό το pattern στο RealtimeAudioCaptureService, επειδή η συνήθεια του pattern να κάνει close() κάθε φορά αποδείχτηκε ότι ήταν το bug. Οι δύο υπηρεσίες συνεχίζουν να μαθαίνουν η μία από τον χειρισμό του audio κύκλου ζωής της άλλης, μία διόρθωση τη φορά.

Τρία πράγματα που μας έμαθαν αυτές οι εκδόσεις

  1. Ένα AudioContext έχει μια μηχανή καταστάσεων — αντιμετώπισε το state ως φέρον. Τα running, suspended, και closed δεν είναι ασήμαντα. Το λειτουργικό μπορεί να μετακινήσει ένα context ανάμεσά τους χωρίς να σε ρωτήσει, και διαφορετικά μέρη του graph σου αντιδρούν διαφορετικά (ένα ScriptProcessorNode στο κύριο νήμα συνεχίζει· ο προορισμός που τακτοποιείται από το hardware ρολόι όχι). Αν δεν παρακολουθείς το onstatechange, εμπιστεύεσαι ότι το context μένει εκεί που το άφησες — και σε ένα πραγματικό desktop, υπό αλλαγές εστίασης και καταγραφή οθόνης, δεν θα μείνει.
  2. Το close() δεν είναι free(). Η διαίσθηση ότι το να κλείνεις έγκαιρα έναν πόρο είναι πάντα σωστό είναι ακριβώς αυτό που προκάλεσε το δεύτερο bug. Όταν ο πόρος είναι μια θέση σε ένα σκληρό, επιβεβλημένο από τον browser pool που ανακτάται μόνο κατά τον χρόνο GC, το επιθετικό κλείσιμο είναι ο τρόπος με τον οποίο εξαντλείς τον pool. Μερικές φορές ο σωστός κύκλος ζωής είναι δημιούργησε-μία-φορά-suspend-συχνά, όχι δημιούργησε-και-κατέστρεψε-ανά-χρήση.
  3. Μερικά bugs είναι προσβάσιμα μόνο μέσα από την πόρτα του πραγματικού hardware και του πραγματικού χρόνου. Το „πεθαίνει μετά από περίπου έξι sessions" και το „βουβαίνει όταν αλλάζεις καρτέλα" δεν μπορούν να πιαστούν από ένα unit test που κάνει mock τη συσκευή ήχου — και η ομάδα το είπε ξεκάθαρα αντί να το κρύψει με ένα ψευτο-περνώντας test. Ονόμασε τα όρια του test harness σου· ένα bug που χρειάζεται οκτώ πραγματικές sessions για να εμφανιστεί αξίζει ένα έντιμο „επαληθεύτηκε χειροκίνητα", όχι ένα πράσινο τικ που δεν σημαίνει τίποτα.

Για το προηγούμενο κεφάλαιο, ξεχωρίζοντας μια κλήση από μια ανοιχτή εφαρμογή (v1.8.15–v1.8.19)· και για ολόκληρη την πορεία, η ανατομία της παράδοσης λογισμικού στην τελειότητα.

Σχετικά Άρθρα

Η σιωπή ήταν φέρουσα
Steven
Steven8 λεπτά ανάγνωσης

Η σιωπή ήταν φέρουσα

Οι δύο τελευταίες εκδόσεις του GeekBye v1 αφορούν την ίδια άβολη αλήθεια: η μεταγραφή σε πραγματικό χρόνο πάνω από ένα πραγματικό δίκτυο δεν είναι χωρίς απώλειες, και η έντιμη κίνηση είναι να σταματήσεις να προσποιείσαι ότι είναι. Η v1.8.20 κρατούσε ένα αντίγραφο κάθε κομματιού ήχου στον δίσκο πριν το πετάξει κατά τη διάρκεια μιας επανασύνδεσης, και άρχισε να σημειώνει φωναχτά τα κενά στη μεταγραφή. Η v1.9.0 σταμάτησε να στέλνει σιωπή για να εξοικονομήσει εύρος ζώνης — και ανακάλυψε ότι η σιωπή ήταν ακριβώς το σήμα που χρησιμοποιούσε το σύστημα μεταγραφής για να ξέρει ότι μια πρόταση είχε τελειώσει. Δύο εκδόσεις για το κόστος του να πετάς πράγματα.

Μηχανική
Audio
Αξιοπιστία
Ξεχωρίζοντας μια κλήση από μια ανοιχτή εφαρμογή
Steven
Steven9 λεπτά ανάγνωσης

Ξεχωρίζοντας μια κλήση από μια ανοιχτή εφαρμογή

Το GeekBye μπορεί να παρατηρήσει ότι μπήκες σε μια βιντεοκλήση και να σου προσφέρει να την καταγράψει. Η ανίχνευση αποδεικνύεται το εύκολο μισό — ένα Swift binary που διαβάζει τίτλους παραθύρων κάθε δέκα δευτερόλεπτα. Το δύσκολο μισό είναι η ακρίβεια: να μην πυροδοτείται όταν το Zoom είναι απλώς ανοιχτό, να μη ρωτά για μια σύσκεψη που ήδη καταγράφεις, και να μη σιγάζει το μικρόφωνο στην κλήση στην οποία πραγματικά βρίσκεσαι. Τρεις εκδόσεις, και η καθεμία είναι μια δικλείδα που έπρεπε να μάθει να μην νικά τον εαυτό της.

Μηχανική
macOS
Desktop
Βγάζοντας το backend από τη διαδρομή του upload
Steven
Steven9 λεπτά ανάγνωσης

Βγάζοντας το backend από τη διαδρομή του upload

Το GeekBye καταγράφει την οθόνη σου και αποθηκεύει το βίντεο στο Google Drive σου. Η πρώτη έκδοση έστελνε κάθε καταγραφή μέσα από τους ίδιους τους διακομιστές του GeekBye στη διαδρομή προς τα εκεί· μια έκδοση αργότερα, το αρχείο πήγαινε κατευθείαν από το μηχάνημά σου στο Drive, και το backend υποβιβάστηκε στο να κρατά έναν μοναδικό δείκτη. Το ενδιαφέρον κομμάτι είναι πόσο λίγο κώδικα περιέχει στην πραγματικότητα η «άμεση, resumable» έκδοση — επειδή η resumability προήλθε από τη διαγραφή ενός proxy, όχι από τη συγγραφή ενός.

Μηχανική
Αρχιτεκτονική
Desktop