Steven
Steven8 min läsning

De tre verben som håller Web Audio vid liv

Två GeekBye-punktreleaser, två månader isär och i två olika filer, lärde vår ljudkod samma läxa från motsatta håll: sluta behandla webbläsarens AudioContext som slit-och-släng. Den ena releasen lärde sig att anropa resume() på en kontext som macOS tyst hade suspenderat mitt i inspelningen; den andra lärde sig att anropa suspend() i stället för close() så att inspelningar i följd slutar dundra in i Chromiums tak på ungefär sex kontexter. Resume, suspend, close — det är hela handlingen.

Utveckling
Audio
Desktop
GeekBye-releaser
De tre verben som håller Web Audio vid liv

För det mesta beter sig en Web Audio-AudioContext som ett vanligt objekt: du konstruerar en, kopplar ihop några noder och glömmer bort den. Två GeekBye-releaser är vad som hände när det antagandet brast — två gånger, i två olika delar av appen, två månader isär. Ingendera planerades som en uppföljare till den andra. Men ställ dem sida vid sida och de berättar en historia: webbläsarens AudioContext är inte ett slit-och-släng-objekt. Det är en knapp, operativsystemsägd resurs med en riktig tillståndsmaskin, och den tillståndsmaskinens tre verb — resume, suspend, close — är hela handlingen i båda releaserna.

GeekBye har två separata ljudpipelines, vilket är varför samma läxa fick läras två gånger. Den ena är videoinspelningsvägen (ScreenRecordingService), som bygger en Web Audio-graf för att mixa mikrofon- och systemljud till en inspelad videofil. Den andra är realtidstranskriberingsvägen (RealtimeAudioCaptureService), som bygger sin egen graf för att mata tal-till-text. Var och en har sin egen AudioContext. Var och en av releaserna nedan fixar en av dem.

v1.8.16: videon blev tyst, men transkriptet gjorde det inte

Den första buggen är ett kluvet sinne. Du spelar in ett möte, du växlar bort till en annan app ett ögonblick, och något subtilt händer: videofilen tappar din mikrofon medan livetranskriptet fortsätter rulla som om ingenting förändrats. Transkriptet svär på att du talade. Videon är tyst. Det är en vansinnesframkallande sorts bugg eftersom de två saker som borde vara överens — ”hörde mikrofonen dig” — är rakt av oense.

Committen som fixar det namnger orsaken exakt:

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.

Här är mekaniken. ScreenRecordingService.setupMonoMixingPipeline() bygger en AudioContext, en MediaStreamDestination som blir videons ljudspår, en GainNode för mute, och ett par ScriptProcessorNode:er (via createScriptProcessor) som pumpar ljudrutor till VAD/STT-logiken. När macOS suspenderar kontexten — vilket den kan göra när appen tappar fokus under en skärminspelning — stannar de delar av grafen som drivs av ljudhårdvarans klocka: MediaStreamDestination slutar ta emot mikrofonljud, så den inspelade videon tystnar. Men en ScriptProcessorNode:s onaudioprocess-callback körs på JavaScript-huvudtråden, och den tråden fortsätter ticka. Så STT-sidan fortsätter producera transkriptposter från vad den senast hade, och de två konsumenterna av samma mikrofon divergerar.

Fixen är att märka suspenderingen och ångra den:

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

Det är hela korrekthetsändringen: bevaka kontextens state, och om den faller till suspended medan vi fortfarande antas spela in, anropa resume(). Releasen kopplar också in onmute-, onunmute- och onended-lyssnare på mikrofonens MediaStreamTrack — inte för att ändra beteende, utan så att en mute på OS-nivå eller en enhetsfrånkoppling dyker upp i loggarna i stället för att vara en osynlig orsak till tystnad. När en buggs hela svårighet är ”varför slutade ljudet” är det halva fixen att göra ljudets upphörande observerbart.

En uppföljande commit landade ett par minuter senare för att städa efter de nya lyssnarna: cleanup() nollar nu micTrack.onmute/onunmute/onended och sätter audioContext.onstatechange = null innan den river ner grafen. Commit-noten säger att den ”matchar RealtimeAudioCaptureService städmönster” — appens andra ljudtjänst. Håll fast vid den detaljen, för två månader senare är det exakt det mönstret som nästa release fick skriva om.

v2.0.2: ljud och transkript dör efter ungefär sex sessioner

Den andra buggen bor i den andra pipelinen, RealtimeAudioCaptureService, och den har en underbar form: allt fungerar perfekt, och sedan efter att du startat och stoppat inspelning en handfull gånger under en appsession bara... dör ljudet. Först tystnar systemljudet, sedan slutar transkriptet. Starta om appen och det är bra igen — tills ännu en handfull sessioner.

Rotorsaksnoten är värd att läsa i sin helhet, för den är en lärobok-bugg-bakom-buggen:

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

Fällan är att den gamla koden gjorde det ”ansvarsfulla”. Varje start() skapade en färsk AudioContext; varje cleanup() anropade close() på den. Att stänga en resurs när du är klar med den är precis vad du lärs att göra. Det fanns till och med en inline-kommentar som rationaliserade omsättningen — att ladda om AudioWorklet-modulen per färsk kontext var okej eftersom ”en färsk kontext per start() betyder att vi alltid betalar denna kostnad en gång.”

Men Chromium begränsar hur många hårdvarustödda AudioContext:er en enda sida kan hålla — ungefär sex — och, avgörande nog, close() lämnar inte tillbaka platsen i samma ögonblick som du anropar den. En stängd kontext fortsätter uppta sin plats tills den skräpsamlas, vilket sker närhelst JS-motorn kommer runt till det. Så en samvetsgrann stäng-varje-gång-policy är precis det som går rakt in i taket: sex skapa/stäng-cykler senare hålls alla sex platser av ännu icke-insamlade lik, den sjunde new AudioContext() kommer tillbaka död, och varje nod du fäster vid den gör tyst ingenting.

Fixen är att sluta behandla kontexten som slit-och-släng. Skapa den en gång och återanvänd den under tjänstens livstid:

  • I setupMonoMixingPipeline, konstruera bara en new AudioContext(...) om det inte redan finns en eller den befintliga är closed. Om den finns men är suspended, anropa await audioContext.resume() i stället för att göra en ny.
  • I cleanup, ersätt audioContext.close() med audioContext.suspend() (skyddat så att det aldrig anropas på en redan stängd kontext). Kontexten överlever sessionen.
  • Eftersom kontexten överlever, överlever den laddade AudioWorklet-modulen med den — koden återställer inte längre sin ”worklet laddad”-flagga vid städning, så audioWorklet.addModule(...) betalas en gång för hela tjänstens livstid i stället för varje session.

De sessionsspecifika bitarna — createMediaStreamSource-noderna för mikrofon och systemljud, mixer-workleten, och den tysta passthrough som håller Chromiums systemljuds-loopback-tapp vid liv — byggs och rivs fortfarande varje cykel. Bara AudioContext själv består, så det finns som mest en i taget. Taket är onåbart eftersom du aldrig staplar kontexter mot det.

En ärlig notering committen gör om sig själv: detta verifierades för hand — ”manuellt verifierat: 8+ sessioner i följd håller systemljudet hörbart och transkript flödande (var dött vid session 7)” — och författaren är uppriktig med att Web Audio-livscykeln ”inte är enhetstestbar i jsdom utan en stor ny mock-rigg”. Det är rätt sorts ärlighet. Felet uppträder bara mot en riktig ljudenhet över riktiga sessioner; en grön enhetssvit hade inte bevisat någonting. ”Ungefär sex” och ”dött vid session 7” är teamets observationer av var Chromiums tak bet, inte en dokumenterad konstant — koden själv garderar sig med ett tildetecken, och det bör var och en som upprepar den också.

Resume, suspend, close

Ställ upp de två releaserna och symmetrin är nästan för prydlig. Båda handlar om samma tre metoder på samma sorts objekt, angripna från motsatta riktningar:

  • v1.8.16 lärde sig att anropa resume(). Kontexten blev suspended under fötterna av OS:et, och fixen är att fånga det och återuppta — hålla en kontext som ska köra, körande.
  • v2.0.2 lärde sig att anropa suspend() i stället för close(). Den gamla koden var för ivrig att förstöra kontexten, och fixen är att suspendera och återanvända den — hålla en kontext som är dyr att återskapa, vid liv.

De är de två halvorna av en enda princip: sluta behandla AudioContext som slit-och-släng. Den ena halvan säger låt den inte dö när den inte borde; den andra säger döda den inte när du inte måste. Och det finns en liten ironi i tidslinjen — v1.8.16:s städning skrevs för att ”matcha RealtimeAudioCaptureService:s städmönster”, och sedan skrev v2.0.2 om exakt det mönstret i RealtimeAudioCaptureService, eftersom mönstrets close()-varje-gång-vana visade sig vara buggen. De två tjänsterna fortsätter lära sig av varandras hantering av ljudlivscykeln, en fix i taget.

Tre saker dessa releaser lärde ut

  1. En AudioContext har en tillståndsmaskin — behandla state som bärande. running, suspended och closed är inte trivialiteter. OS:et kan flytta en kontext mellan dem utan att fråga dig, och olika delar av din graf reagerar olika (en ScriptProcessorNode på huvudtråden fortsätter; den hårdvaruklockade destinationen gör det inte). Om du inte bevakar onstatechange litar du på att kontexten stannar där du lämnade den — och på ett riktigt skrivbord, under fokusbyten och skärminspelning, gör den inte det.
  2. close() är inte free(). Intuitionen att prompt stänga en resurs alltid är korrekt är precis det som orsakade den andra buggen. När resursen är en plats i en hård, webbläsarpåtvingad pool som bara återvinns vid GC-tid är aggressiv stängning hur du förbrukar poolen. Ibland är rätt livscykel skapa-en-gång-suspendera-ofta, inte skapa-och-förstör-per-användning.
  3. Vissa buggar är bara nåbara genom dörren av riktig hårdvara och riktig tid. ”Dör efter ungefär sex sessioner” och ”blir tyst när du växlar bort” kan inte fångas av ett enhetstest som mockar ljudenheten — och teamet sa det rakt ut i stället för att dölja det med ett falskt-godkänt test. Namnge gränserna för din testrigg; en bugg som behöver åtta riktiga sessioner för att dyka upp förtjänar ett ärligt ”manuellt verifierat”, inte en grön bock som inte betyder någonting.

För det föregående kapitlet, att skilja ett samtal från en öppen app (v1.8.15–v1.8.19); och för hela bågen, anatomin i att skeppa mjukvara till perfektion.

Relaterade artiklar

Tystnaden var bärande
Steven
Steven7 min läsning

Tystnaden var bärande

GeekBye v1:s två sista releaser handlar om samma obekväma sanning: realtidstranskribering över ett riktigt nätverk är inte förlustfri, och det ärliga draget är att sluta låtsas att den är det. v1.8.20 behöll en kopia av varje ljudbit på disk innan den slängdes under en återanslutning, och började markera transkriptets luckor högt. v1.9.0 slutade skicka tystnad för att spara bandbredd — och upptäckte att tystnaden var exakt den signal transkriberaren använde för att veta att en mening hade tagit slut. Två releaser om priset för att slänga saker.

Utveckling
Audio
Tillförlitlighet
Att skilja ett samtal från en öppen app
Steven
Steven8 min läsning

Att skilja ett samtal från en öppen app

GeekBye kan märka att du har gått med i ett videomöte och erbjuda sig att spela in det. Detekteringen visar sig vara den lätta halvan — en Swift-binär som läser fönstertitlar var tionde sekund. Den svåra halvan är precision: att inte lösa ut när Zoom bara är öppet, att inte fråga om ett möte du redan spelar in, och att inte tysta mikrofonen i samtalet du faktiskt sitter i. Tre releaser, och var och en är en skyddsspärr som måste lära sig att inte besegra sig själv.

Utveckling
macOS
Desktop
Att ta backend ur uppladdningsvägen
Steven
Steven8 min läsning

Att ta backend ur uppladdningsvägen

GeekBye spelar in din skärm och sparar videon till din Google Drive. Den första versionen skickade varje inspelning genom GeekByes egna servrar på vägen dit; en release senare gick filen rakt från din maskin till Drive, och backend degraderades till att hålla en enda pekare. Den intressanta delen är hur lite kod den 'direkta, återupptagbara' versionen faktiskt innehåller — eftersom återupptagbarheten kom från att radera en proxy, inte från att skriva en.

Utveckling
Arkitektur
Desktop