Steven
Steven10 min de lecture

Les trois verbes qui gardent le Web Audio en vie

Deux versions ponctuelles de GeekBye, à deux mois d'écart et dans deux fichiers différents, ont enseigné à notre code audio la même leçon depuis des extrémités opposées : cesser de traiter l'AudioContext du navigateur comme jetable. Une version a appris à faire resume() d'un contexte que macOS avait silencieusement suspendu en pleine session d'enregistrement ; l'autre a appris à faire suspend() au lieu de close() pour que des sessions consécutives cessent de percuter le plafond d'environ six contextes de Chromium. Resume, suspend, close — voilà toute l'intrigue.

Ingénierie
Audio
Desktop
Versions de GeekBye
Les trois verbes qui gardent le Web Audio en vie

La plupart du temps, un AudioContext de Web Audio se comporte comme un objet ordinaire : vous en construisez un, câblez quelques nœuds, et l'oubliez. Deux versions de GeekBye, c'est ce qui est arrivé quand cette hypothèse s'est brisée — deux fois, dans deux parties différentes de l'app, à deux mois d'écart. Aucune n'était planifiée comme la suite de l'autre. Mais mettez-les côte à côte et elles racontent une seule histoire : l'AudioContext du navigateur n'est pas un objet jetable. C'est une ressource rare, propriété du système d'exploitation, avec une vraie machine à états, et les trois verbes de cette machine à états — resume, suspend, close — sont toute l'intrigue des deux versions.

GeekBye a deux pipelines audio distincts, et c'est pourquoi la même leçon a dû être apprise deux fois. L'un est le chemin d'enregistrement vidéo (ScreenRecordingService), qui construit un graphe Web Audio pour mélanger le micro et l'audio système dans un fichier vidéo enregistré. L'autre est le chemin de transcription en temps réel (RealtimeAudioCaptureService), qui construit son propre graphe pour alimenter la reconnaissance vocale. Chacun a son propre AudioContext. Chaque version ci-dessous en corrige un.

v1.8.16 : la vidéo est devenue muette, mais pas la transcription

Le premier bug est un cerveau dédoublé. Vous enregistrez une réunion, vous basculez un instant vers une autre app, et quelque chose de subtil se produit : le fichier vidéo perd votre microphone pendant que la transcription en direct continue de défiler comme si de rien n'était. La transcription jure que vous parliez. La vidéo est muette. C'est un genre de bug exaspérant parce que les deux choses qui devraient s'accorder — « le micro vous a-t-il entendu » — sont en désaccord total.

Le commit qui le corrige nomme la cause avec précision :

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.

Voici la mécanique. ScreenRecordingService.setupMonoMixingPipeline() construit un AudioContext, un MediaStreamDestination qui devient la piste audio de la vidéo, un GainNode pour la coupure, et une paire de ScriptProcessorNode (via createScriptProcessor) qui pompent des trames audio vers la logique VAD/STT. Quand macOS suspend le contexte — ce qu'il peut faire quand l'app perd le focus pendant un enregistrement d'écran — les parties du graphe pilotées par l'horloge du matériel audio s'arrêtent : le MediaStreamDestination cesse de recevoir l'audio du micro, si bien que la vidéo enregistrée devient silencieuse. Mais le callback onaudioprocess d'un ScriptProcessorNode tourne sur le thread principal JavaScript, et ce thread continue de tourner. Alors le côté STT continue de produire des entrées de transcription à partir de ce qu'il avait en dernier, et les deux consommateurs du même microphone divergent.

Le correctif, c'est de remarquer la suspension et de l'annuler :

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

C'est tout le changement de correction : surveiller le state du contexte, et s'il tombe à suspended alors que nous sommes censés encore enregistrer, appeler resume(). La version câble aussi des listeners onmute, onunmute et onended sur le MediaStreamTrack du microphone — non pas pour changer le comportement, mais pour qu'une coupure au niveau de l'OS ou une déconnexion d'appareil apparaisse dans les logs au lieu d'être une cause invisible de silence. Quand toute la difficulté d'un bug est « pourquoi l'audio s'est-il arrêté », rendre l'arrêt de l'audio observable est la moitié du correctif.

Un commit de suivi a atterri quelques minutes plus tard pour nettoyer après ces nouveaux listeners : cleanup() met désormais à null micTrack.onmute/onunmute/onended et fixe audioContext.onstatechange = null avant de démonter le graphe. La note du commit dit qu'il « correspond au pattern de nettoyage de RealtimeAudioCaptureService » — l'autre service audio de l'app. Retenez ce détail, parce que deux mois plus tard, c'est exactement ce pattern que la version suivante a dû réécrire.

v2.0.2 : l'audio et les transcriptions meurent après environ six sessions

Le second bug vit dans l'autre pipeline, RealtimeAudioCaptureService, et il a une forme merveilleuse : tout fonctionne parfaitement, puis après avoir démarré et arrêté l'enregistrement une poignée de fois dans une même session de l'app, l'audio... meurt tout simplement. D'abord l'audio système devient silencieux, puis la transcription s'arrête. Redémarrez l'app et tout va bien à nouveau — jusqu'à une autre poignée de sessions.

La note de cause racine mérite d'être lue en entier, car c'est un bug-derrière-le-bug de manuel :

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

Le piège, c'est que l'ancien code faisait la chose « responsable ». Chaque start() créait un AudioContext tout neuf ; chaque cleanup() lui appelait close(). Fermer une ressource quand on en a fini avec elle est exactement ce qu'on vous apprend à faire. Il y avait même un commentaire en ligne qui rationalisait le brassage — que recharger le module AudioWorklet par contexte neuf était acceptable parce qu'« un contexte neuf par start() signifie qu'on paie toujours ce coût une fois ».

Mais Chromium limite combien d'AudioContext adossés au matériel une seule page peut détenir — environ six — et, chose cruciale, close() ne rend pas le slot à l'instant où vous l'appelez. Un contexte fermé continue d'occuper son slot jusqu'à ce qu'il soit collecté par le ramasse-miettes, ce qui arrive quand le moteur JS s'y met. Alors une politique scrupuleuse de fermer-à-chaque-fois est précisément ce qui vous mène dans le plafond : six cycles créer/fermer plus tard, les six slots sont tous détenus par des cadavres pas encore collectés, le septième new AudioContext() revient mort, et chaque nœud que vous y attachez ne fait silencieusement rien.

Le correctif, c'est de cesser de traiter le contexte comme jetable. Le créer une fois et le réutiliser pour toute la durée de vie du service :

  • Dans setupMonoMixingPipeline, ne construire un new AudioContext(...) que s'il n'y en a pas encore ou que l'existant est closed. S'il existe mais est suspended, faire await audioContext.resume() au lieu d'en créer un nouveau.
  • Dans cleanup, remplacer audioContext.close() par audioContext.suspend() (protégé pour ne jamais être appelé sur un contexte déjà fermé). Le contexte survit à la session.
  • Parce que le contexte survit, le module AudioWorklet chargé survit avec lui — le code ne réinitialise plus son flag « worklet chargé » au nettoyage, si bien que audioWorklet.addModule(...) n'est payé qu'une fois pour toute la durée de vie du service plutôt qu'à chaque session.

Les pièces par session — les nœuds createMediaStreamSource pour le micro et l'audio système, le worklet mixeur, et le passthrough silencieux qui garde en vie la prise de loopback de l'audio système de Chromium — sont toujours construites et démontées à chaque cycle. Seul l'AudioContext lui-même persiste, si bien qu'il en existe au plus un à la fois. Le plafond est hors d'atteinte parce que vous n'empilez jamais de contextes contre lui.

Une note honnête que le commit fait sur lui-même : ceci a été vérifié à la main — « vérifié manuellement : 8+ sessions consécutives gardent l'audio système audible et les transcriptions qui coulent (était mort à la session 7) » — et l'auteur admet franchement que le cycle de vie de Web Audio « n'est pas testable unitairement dans jsdom sans un grand nouveau harnais de mocks ». C'est le bon genre d'honnêteté. La défaillance n'apparaît que face à un vrai appareil audio à travers de vraies sessions ; une suite unitaire au vert n'aurait rien prouvé. Le « environ six » et le « mort à la session 7 » sont les observations de l'équipe de là où le plafond de Chromium a mordu, pas une constante documentée — le code lui-même se couvre avec un tilde, et quiconque le répète devrait faire de même.

Resume, suspend, close

Alignez les deux versions et la symétrie est presque trop nette. Toutes deux portent sur les trois mêmes méthodes sur le même genre d'objet, abordées depuis des directions opposées :

  • La v1.8.16 a appris à faire resume(). Le contexte s'est fait suspended sous ses pieds par l'OS, et le correctif est d'attraper ça et de reprendre — garder en marche un contexte censé être en marche.
  • La v2.0.2 a appris à faire suspend() au lieu de close(). L'ancien code était trop pressé de détruire le contexte, et le correctif est de le suspendre et de le réutiliser — garder en vie un contexte coûteux à recréer.

Ce sont les deux moitiés d'un seul principe : cesser de traiter l'AudioContext comme un jetable. Une moitié dit : ne le laissez pas mourir quand il ne le devrait pas ; l'autre dit : ne le tuez pas quand vous n'y êtes pas obligé. Et il y a une petite ironie dans la chronologie — le nettoyage de la v1.8.16 a été écrit pour « correspondre au pattern de nettoyage de RealtimeAudioCaptureService », puis la v2.0.2 a réécrit exactement ce pattern dans RealtimeAudioCaptureService, parce que l'habitude de close()-à-chaque-fois du pattern s'est avérée être le bug. Les deux services continuent d'apprendre l'un de l'autre sur la gestion du cycle de vie audio, un correctif à la fois.

Trois choses que ces versions nous ont apprises

  1. Un AudioContext a une machine à états — traitez state comme porteur. running, suspended et closed ne sont pas des détails. L'OS peut déplacer un contexte entre eux sans vous demander, et différentes parties de votre graphe réagissent différemment (un ScriptProcessorNode sur le thread principal continue ; la destination cadencée par le matériel, non). Si vous ne surveillez pas onstatechange, vous faites confiance au fait que le contexte reste où vous l'avez laissé — et sur un vrai poste de travail, sous les changements de focus et l'enregistrement d'écran, ce ne sera pas le cas.
  2. close() n'est pas free(). L'intuition que fermer une ressource promptement est toujours correct est exactement ce qui a causé le second bug. Quand la ressource est un slot dans un pool dur, imposé par le navigateur, qui n'est récupéré qu'au moment du GC, fermer agressivement est la façon d'épuiser le pool. Parfois le bon cycle de vie est créer-une-fois-suspendre-souvent, pas créer-et-détruire-à-chaque-usage.
  3. Certains bugs ne sont atteignables que par la porte du vrai matériel et du temps réel. « Meurt après environ six sessions » et « devient muet quand vous basculez ailleurs » ne peuvent pas être attrapés par un test unitaire qui simule l'appareil audio — et l'équipe l'a dit franchement au lieu de le masquer avec un test au faux-vert. Nommez les limites de votre harnais de test ; un bug qui a besoin de huit vraies sessions pour apparaître mérite un honnête « vérifié manuellement », pas une coche verte qui ne veut rien dire.

Pour le chapitre précédent, distinguer un appel d'une app ouverte (v1.8.15–v1.8.19) ; et pour tout l'arc, l'anatomie de livrer un logiciel jusqu'à la perfection.

Articles Connexes

Le silence était porteur
Steven
Steven9 min de lecture

Le silence était porteur

Les deux dernières versions de GeekBye v1 tournent autour de la même vérité inconfortable : la transcription en temps réel sur un vrai réseau n'est pas sans perte, et le geste honnête est de cesser de prétendre le contraire. La v1.8.20 gardait sur disque une copie de chaque chunk audio avant de le jeter pendant une reconnexion, et s'est mise à marquer les trous dans la transcription à voix haute. La v1.9.0 a cessé d'envoyer le silence pour économiser de la bande passante — et a découvert que le silence était justement le signal que le transcripteur utilisait pour savoir qu'une phrase s'était terminée. Deux versions sur le coût de jeter les choses.

Ingénierie
Audio
Fiabilité
Distinguer un appel d'une app ouverte
Steven
Steven10 min de lecture

Distinguer un appel d'une app ouverte

GeekBye peut remarquer que vous avez rejoint une réunion vidéo et vous proposer de l'enregistrer. La détection s'avère être la moitié facile — un binaire Swift qui lit les titres de fenêtre toutes les dix secondes. La moitié difficile, c'est la précision : ne pas se déclencher quand Zoom est simplement ouvert, ne pas proposer d'enregistrer une réunion que vous enregistrez déjà, et ne pas couper le microphone dans l'appel où vous êtes réellement. Trois versions, et chacune est un garde-fou qui a dû apprendre à ne pas se saboter lui-même.

Ingénierie
macOS
Desktop
Sortir le backend du chemin d'upload
Steven
Steven9 min de lecture

Sortir le backend du chemin d'upload

GeekBye enregistre votre écran et sauvegarde la vidéo sur votre Google Drive. La première version faisait passer chaque enregistrement par les propres serveurs de GeekBye en chemin ; une version plus tard, le fichier allait directement de votre machine à Drive, et le backend était rétrogradé à ne tenir qu'un simple pointeur. Le plus intéressant est le peu de code que contient réellement la version « directe et reprenable » — parce que la reprise venait de la suppression d'un proxy, pas de l'écriture d'un.

Ingénierie
Architecture
Desktop