Steven
Steven5 min leestijd

Hoe je een live rapport streamt zonder geflikker

Wanneer je meeting eindigt, vult GeekBye's samenvatting zich nu live in plaats van je naar een spinner te laten staren. Streaming-UI kalm laten aanvoelen in plaats van schokkerig vereiste dat we geflikker twee keer oplosten — één keer voor gestructureerde velden, één keer voor markdown — en de tweede fix was een renderer die we al bezaten.

Engineering
Frontend
Streaming
GeekBye Releases
Hoe je een live rapport streamt zonder geflikker

Vóór deze release betekende het beëindigen van een GeekBye-meeting dat je naar een spinner keek. De app stuurde je hele transcript weg voor analyse, wachtte op de volledige samenvatting — score, kernpunten, actiepunten, alles — en dropte het dan in één keer op het scherm. Functioneel, maar het voelde traag, omdat je naar niets zat te staren terwijl het werkte.

GeekBye v1.6.13–v1.6.15 veranderde dat in een rapport dat live binnenstroomt, veld voor veld, terwijl het wordt gegenereerd. Het interessante deel is niet het streamen — het is alles wat we moesten doen om te voorkomen dat het streamen er schokkerig uitzag. Kalm streamen en hakkelig streamen zijn dezelfde feature met een heel verschillende uitvoering.

Geflikker, deel één: laat niet elke chunk React laten stuiptrekken

De samenvatting is niet één blob — het zijn gestructureerde velden (een totaalscore, kernpunten, actiepunten, enzovoort) die de backend één voor één uitzendt over server-sent events. De naïeve manier om dat te renderen is React-state bij te werken bij elke event zodra die binnenkomt.

Doe dat, en je krijgt geflikker. Elk binnenkomend veld triggert zijn eigen re-render; componenten die "leeg" toonden mounten, unmounten dan, en remounten als "geladen"; de layout schokt bij elk pakket. Het rapport zet zichzelf in elkaar voor je ogen op de slechtst mogelijke manier — zichtbaar, nerveus.

De fix is een tweedelige discipline. Ten eerste, accumuleer de binnenkomende velden in een ref — een muteerbare houder die geen renders triggert — en publiceer één keer per chunk naar React-state met een verse kopie, zodat de tree weloverwogen wordt bijgewerkt in plaats van bij elke micro-event. Ten tweede, render de hele tijd één stabiele component-tree, of het nu streamt of klaar is, met inline placeholders voor velden die nog niet zijn aangekomen:

// accumulate in a ref, publish once per chunk
partialRef.current = { ...partialRef.current, [field]: value }
setPartial({ ...partialRef.current })

// one tree, never swapped; missing fields show a placeholder in place
const report = isStreaming ? partial : saved
<Score>{report.overallScore ?? '…'}</Score>

De component die de score toont unmount nooit — het toont gewoon totdat het getal landt, dan het getal. Niets stuiptrekt. En het volledig samengestelde object wordt aan het eind nog steeds gecachet naar de lokale database, zodat streaming-UI en duurzame opslag naast elkaar bestaan zonder te vechten.

Geflikker, deel twee: de renderer die we al bezaten

Het tweede geflikker is subtieler en leeft in de markdown zelf. Het rapport rendert rijke markdown — koppen, vet, lijsten, codeblokken. Maar markdown die midden in de stream aankomt is, op elk gegeven moment, half af. Een lijst met tot nu toe één bullet. Een code fence die is geopend maar niet gesloten. Een vet-marker zonder partner nog.

Een conventionele markdown-renderer herparseert de hele string bij elke token, en die half-afgemaakte staat parseert elke keer naar iets anders — dus de lijst flikkert, het codeblok flitst open en dicht, de layout springt terwijl tokens compleet worden. Het is dezelfde nerveuze assemblage als eerder, één laag dieper.

De fix was bijna gênant beschikbaar: we gebruikten al een streaming-bewuste markdown-renderer voor de live AI-chat. Een renderer gebouwd om incomplete tokens te tolereren — om half-afgemaakte markdown stabiel te renderen en het pas te laten settelen als de tokens compleet worden — in plaats van elke keer opnieuw vanaf nul te parseren. Het rapport hoefde alleen dezelfde te gebruiken. We hadden dit exacte probleem maanden eerder voor de chat opgelost; de "fix" voor het rapport was beseffen dat we de tool al hadden en die op een tweede oppervlak richten. Hergebruik won van herbouwen.

De bonus: rijk kopiëren is een klembord-formaatprobleem

Zodra het rapport er goed uitzag, wilden mensen het plakken — in een document, een e-mail — en de opmaak en de screenshots behouden. Het instinct is om het rapport te serialiseren naar een markdown-string en te hopen dat de bestemming het rendert. Dat doet het meestal niet.

Het echte antwoord is dat het klembord meerdere representaties tegelijk bevat. Dus schrijven we er twee: een HTML-versie met de opmaak intact en de screenshots ingebed als inline afbeeldingen, en een plaintext-fallback voor bestemmingen die geen HTML accepteren. Plak in een rijke editor en je krijgt het opgemaakte rapport met afbeeldingen; plak in een plattetekstvak en je krijgt schone tekst. "Rijk kopiëren" was nooit een serialisatieprobleem — het was een kies-het-juiste-klembord-formaat-probleem.

Dezelfde release gaf het rapport ook chat-achtige Me / Them-labels met de twee sprekers uitgelijnd aan tegenovergestelde kanten, zodat een transcript leest als het gesprek dat het was, en verplaatste je eigen screenshots naar hun eigen kant om te matchen. (Eén release in dit venster, v1.6.14, was een pure rebuild — geen verhaal daar, en het is eerlijk om dat te zeggen.)

Drie dingen die streaming-UI ons leerde

  1. Buffer gestreamde velden, publiceer één keer per chunk. Elke server-sent event zijn eigen state-update laten aansturen is het geflikker. Een ref-accumulator plus één publish per chunk, in een tree die nooit unmount, verandert nerveuze assemblage in kalm invullen.
  2. Alles wat streamt heeft een streaming-bewuste renderer nodig. Een renderer die de hele string per token herparseert zal flikkeren op half-afgemaakte markdown. Gebruik er een gebouwd voor incomplete input — en als je er al een hebt voor een ander oppervlak, hergebruik die voordat je een tweede bouwt.
  3. Rijk kopiëren gaat over klembord-formaten, niet serialisatie. Schrijf text/html met ingebedde afbeeldingen en een text/plain-fallback. Het klembord is ontworpen om beide te dragen; gebruik het.

Dit is het vijfde hoofdstuk van het betrouwbaarheid-en-afwerking-verhaal dat GeekBye v2 wordt. Voor het vorige hoofdstuk, zie de oordopjes-bug (v1.6.12); voor waar hetzelfde server-sent-events-idee later een heel fallback-transport werd, live transcriptie wanneer de firewall WebSockets blokkeert (v2.0.8); en voor de hele boog, de anatomie van software tot in de perfectie uitbrengen.

Gerelateerde Artikelen

De stilte was dragend
Steven
Steven8 min leestijd

De stilte was dragend

De laatste twee releases van GeekBye v1 gaan over dezelfde ongemakkelijke waarheid: real-time transcriptie over een echt netwerk is niet verliesvrij, en de eerlijke zet is te stoppen met doen alsof het dat wel is. v1.8.20 bewaarde een kopie van elke audiochunk op schijf voordat het die tijdens een reconnect weggooide, en begon de gaten in het transcript hardop te markeren. v1.9.0 stopte met het versturen van stilte om bandbreedte te besparen — en ontdekte dat de stilte precies het signaal was dat de transcriber gebruikte om te weten dat een zin was afgelopen. Twee releases over de kosten van dingen weggooien.

Engineering
Audio
Betrouwbaarheid
De drie werkwoorden die Web Audio in leven houden
Steven
Steven9 min leestijd

De drie werkwoorden die Web Audio in leven houden

Twee GeekBye-point-releases, twee maanden uit elkaar en in twee verschillende bestanden, leerden onze audiocode dezelfde les vanuit tegengestelde kanten: behandel de AudioContext van de browser niet langer als wegwerpbaar. De ene release leerde resume() aan te roepen op een context die macOS stilletjes midden in een opname had gesuspend; de andere leerde suspend() te gebruiken in plaats van close() zodat opeenvolgende sessies niet langer tegen Chromiums plafond van ongeveer zes contexten aan knallen. Resume, suspend, close — dat is het hele plot.

Engineering
Audio
Desktop
Een call onderscheiden van een open app
Steven
Steven9 min leestijd

Een call onderscheiden van een open app

GeekBye kan merken dat je aan een videovergadering bent gaan deelnemen en aanbieden om die op te nemen. De detectie blijkt de makkelijke helft — een Swift-binary die elke tien seconden vensternamen leest. De moeilijke helft is precisie: niet afgaan wanneer Zoom slechts openstaat, niet vragen om een vergadering die je al opneemt, en niet de microfoon dempen in het gesprek waar je daadwerkelijk in zit. Drie releases, en elk ervan is een waarborg die moest leren zichzelf niet te verslaan.

Engineering
macOS
Desktop