Het artikel beschrijft de transitie van een opname-engine in Electron naar een native implementatie in Swift. De belangrijkste reden voor deze wijziging was dat het renderproces van Electron ongeschikt is voor realtime opnames vanwege garbage collection-pauzes en throttling.
Belangrijke technische punten:
- Atomic: Een zelfontwikkelde tool die een bridge slaat tussen Swift (
@Published eigenschappen) en React (Jotai-atoms), waardoor type-veilige communicatie tussen native code en de UI mogelijk is zonder handmatige glue code.
- Platform-specifieke implementaties: Op macOS wordt gebruikgemaakt van ScreenCaptureKit en op Windows van libobs (via een eigen wrapper genaamd OBSKit).
- Technische uitdagingen: Op macOS is complexe logica geïmplementeerd om hardwareklokken te synchroniseren en audio-drift (verkeerde sample-rates van drivers) te corrigeren.
- Databeveiliging: Door over te stappen op fragmented MP4 worden opnames segment voor segment opgeslagen, waardoor data niet verloren gaat bij een applicatiecrash.
De rewrite leidde tot een significante afname van supporttickets en een snellere ontwikkeltijd dankzij de Atomic-bridge.
Hoe we onze Electron-opname-engine hebben herbouwd in Swift
Onze desktop-app legt vergaderingen vast zonder gebruik van een bot en streamt deze naar de cloud. Maandenlang was de opname-engine het moeilijkste onderdeel van het product om betrouwbaar te maken. Telkens wanneer we een specifieke categorie edge-cases oplosten en deze uitbrachten, dook de week erna een nieuwe op. Verschillende grondoorzaken, maar hetzelfde patroon.
De engine draaide in het renderproces van onze Electron-app. We hebben de voor de hand liggende oplossingen geprobeerd: een strikter beheer van de levenscyclus, werk verplaatsen naar een andere thread dan de hoofdthread en de engine isoleren van de render-cyclus van React. Elke wijziging hielp marginaal, maar geen enkele paktte het echte probleem aan. Een renderproces is simpelweg de verkeerde plek voor realtime audio- en video-opname. Een opname-engine kan geen GC-pauzes (garbage collection), throttling of andere mechanismen die een browser-runtime gebruikt om responsief te blijven, tolereren.
Daarom zijn we overgestapt op native: ScreenCaptureKit op macOS, libobs op Windows, en een gedeelde Swift-laag die alles met elkaar verbindt.
Atomic: onze Combine-to-Jotai-bridge
Het overbruggen van een native runtime naar React betekent meestal dat je handmatig native addon-bindings moet schrijven. Je moet elke waarde die de grens overgaat serialiseren, gebeurtenissen routeren via string-gebaseerde namen en drie verschillende bestanden bijwerken telkens wanneer je een eigenschap toevoegt: de Swift-klasse, de C++-binding en de TypeScript-wrapper. Dit werkt, maar raakt direct uit sync zodra iemand een stap vergeet.
Wat als elke @Published-eigenschap in Swift automatisch een Jotai-atom in React werd? Volledig reactief, type-veilig en zonder tussenliggende 'glue code'. Dat is wat onze interne tool, Atomic, doet.
@NodeExport
public final class AudioPlayer {
@Published public var isPlaying: Bool = false
@Published public var volume: Float = 1.0
public func play() { isPlaying = true }
public func pause() { isPlaying = false }
}
#AtomicExport(AudioPlayer.self)
const player = new AudioPlayer();
const volumeAtom = atomWithNativeState<number>(player.volume);
store.set(volumeAtom, 0.5); // Stroomt naar Swift.
player.play(); // Updates stromen terug naar React.
Vanuit het perspectief van React zijn deze atoms niet te onderscheiden van elke andere Jotai-atom. Het feit dat de data leeft in een Swift-runtime op een andere thread, is onzichtbaar.
De @NodeExport-macro genereert de volledige bridge tijdens het compileren. Typen worden automatisch gekoppeld (Int → number, String? → string | null). Waardeaanpassingen in Swift plannen callbacks in op de event-loop van Node. Elke nieuwe eigenschap die we aan de Swift-kant toevoegen, is direct beschikbaar in React. Omdat Atomic is gebouwd op Swift en niet op Apple-frameworks (we gebruiken OpenCombine op Windows), draait dezelfde bridge op beide platforms.
Twee opname-engines, één interface
Op macOS biedt ScreenCaptureKit ons hardware-versnelde opnamemogelijkheden en de native content-picker. Op Windows gebruiken we libobs via een Swift-wrapper die we OBSKit noemen. De twee engines hebben fundamenteel verschillende architecturen.
Op macOS ontvangen we ruwe sample-buffers van drie onafhankelijke bronnen en stellen we het bestand zelf samen. Op Windows draaien opname, mixing, encoding en muxing als één enkele graaf. We configureren dit, waarna een bestandsmonitor nieuw geschreven bytes streamt naar onze uploadsessie.
Opname op Windows kent eigen uitdagingen. We gebruiken Windows Graphics Capture (WGC) als primaire methode; als dit niet op tijd frames levert, vallen we terug op BitBlt. We detecteren daarnaast volledig zwarte frames (veelvoorkomend bij sommige geëmuleerde vensters of games) en wisselen midden in de opname van methode.
Wanneer klokken niet overeenstemmen
Dit is waar de macOS-engine zijn complexiteit rechtvaardigt. Drie opnamebronnen, drie hardwareklokken, drie verschillende ideeën over hoe laat het is.
Beide audiobronnen krijgen een tijdstempel en worden omgezet naar een globale frame-index. De mixer leegt beide wachtrijen synchroon en produceert alleen output wanneer beide voldoende data hebben. Als één bron stokt (gedempte microfoon, bevroren virtueel apparaat), detecteert de mixer dit na 500ms en schakelt over naar de modus voor één enkele bron totdat de andere bron weer actief is.
Daarnaast is er een subtieler probleem: audiodrivers die liegen over hun sample-rate. Sommige virtuele drivers rapporteren 48kHz, maar leveren buffers op 44,1kHz. Tijdens een vergadering van 30 minuten wordt deze drift hoorbaar. Onze oplossing is een correctie op basis van betrouwbaarheid: we meten de werkelijke buffer-cadans, en als deze consistent afwijkt van het gerapporteerde formaat over drie opeenvolgende buffers, interpreteren we de stream opnieuw op de juiste snelheid met een crossfade om klikken te voorkomen.
Op Windows wordt het grootste deel van deze complexiteit geabstraheerd door de interne mixer van de opname-engine. De afweging is controle: op macOS detecteren en herstellen we edge-cases zoals onjuiste drivers zelf; op Windows ruilen we die granulariteit in voor eenvoud.
Opnames die crashes overleven
Een reguliere MP4 schrijft de metadata pas aan het einde van het bestand. Als de app crasht voordat dat gebeurt, is de opname verloren. Op beide platforms gebruiken we daarom in plaats daarvan gefragmenteerde MP4 (fragmented MP4).
Elk segment is zelfvoorzienend. Een crash op minuut 30 betekent dat er hooguit de laatste seconde verloren gaat. Segmenten worden gelijktijdig naar lokale opslag en de cloud gestuurd. Als de netwerkverbinding wegvalt, blijven de segmenten lokaal bewaard en wordt de upload automatisch hervat zodra de connectiviteit hersteld is.
Desktop-opnames waren voorheen een van de meest voorkomende bronnen van supporttickets. Nu is het een saai onderdeel van de app dat gewoon werkt. De volledige rewrite is in twee maanden voltooid, en dat is te danken aan Atomic: zodra de bridge er was, betekende het toevoegen van een functie simpelweg het schrijven van Swift en het in realtime zien updaten van de UI.
Niet alles hoort in een renderproces thuis. Soms moet je native gaan.
Hoe we onze Electron-opname-engine hebben herbouwd in Swift
Onze desktop-app legt vergaderingen vast zonder gebruik van een bot en streamt deze naar de cloud. Maandenlang was de opname-engine het moeilijkste onderdeel van het product om betrouwbaar te maken. Telkens wanneer we een specifieke categorie edge-cases oplosten en deze uitbrachten, dook de week erna een nieuwe op. Verschillende grondoorzaken, maar hetzelfde patroon.
De engine draaide in het renderproces van onze Electron-app. We hebben de voor de hand liggende oplossingen geprobeerd: een strikter beheer van de levenscyclus, werk verplaatsen naar een andere thread dan de hoofdthread en de engine isoleren van de render-cyclus van React. Elke wijziging hielp marginaal, maar geen enkele paktte het echte probleem aan. Een renderproces is simpelweg de verkeerde plek voor realtime audio- en video-opname. Een opname-engine kan geen GC-pauzes (garbage collection), throttling of andere mechanismen die een browser-runtime gebruikt om responsief te blijven, tolereren.
Daarom zijn we overgestapt op native: ScreenCaptureKit op macOS, libobs op Windows, en een gedeelde Swift-laag die alles met elkaar verbindt.
Atomic: onze Combine-to-Jotai-bridge
Het overbruggen van een native runtime naar React betekent meestal dat je handmatig native addon-bindings moet schrijven. Je moet elke waarde die de grens overgaat serialiseren, gebeurtenissen routeren via string-gebaseerde namen en drie verschillende bestanden bijwerken telkens wanneer je een eigenschap toevoegt: de Swift-klasse, de C++-binding en de TypeScript-wrapper. Dit werkt, maar raakt direct uit sync zodra iemand een stap vergeet.
Wat als elke @Published-eigenschap in Swift automatisch een Jotai-atom in React werd? Volledig reactief, type-veilig en zonder tussenliggende 'glue code'. Dat is wat onze interne tool, Atomic, doet.
@NodeExport
public final class AudioPlayer {
@Published public var isPlaying: Bool = false
@Published public var volume: Float = 1.0
public func play() { isPlaying = true }
public func pause() { isPlaying = false }
}
#AtomicExport(AudioPlayer.self)
const player = new AudioPlayer();
const volumeAtom = atomWithNativeState<number>(player.volume);
store.set(volumeAtom, 0.5); // Stroomt naar Swift.
player.play(); // Updates stromen terug naar React.
Vanuit het perspectief van React zijn deze atoms niet te onderscheiden van elke andere Jotai-atom. Het feit dat de data leeft in een Swift-runtime op een andere thread, is onzichtbaar.
De @NodeExport-macro genereert de volledige bridge tijdens het compileren. Typen worden automatisch gekoppeld (Int → number, String? → string | null). Waardeaanpassingen in Swift plannen callbacks in op de event-loop van Node. Elke nieuwe eigenschap die we aan de Swift-kant toevoegen, is direct beschikbaar in React. Omdat Atomic is gebouwd op Swift en niet op Apple-frameworks (we gebruiken OpenCombine op Windows), draait dezelfde bridge op beide platforms.
Twee opname-engines, één interface
Op macOS biedt ScreenCaptureKit ons hardware-versnelde opnamemogelijkheden en de native content-picker. Op Windows gebruiken we libobs via een Swift-wrapper die we OBSKit noemen. De twee engines hebben fundamenteel verschillende architecturen.
Op macOS ontvangen we ruwe sample-buffers van drie onafhankelijke bronnen en stellen we het bestand zelf samen. Op Windows draaien opname, mixing, encoding en muxing als één enkele graaf. We configureren dit, waarna een bestandsmonitor nieuw geschreven bytes streamt naar onze uploadsessie.
Opname op Windows kent eigen uitdagingen. We gebruiken Windows Graphics Capture (WGC) als primaire methode; als dit niet op tijd frames levert, vallen we terug op BitBlt. We detecteren daarnaast volledig zwarte frames (veelvoorkomend bij sommige geëmuleerde vensters of games) en wisselen midden in de opname van methode.
Wanneer klokken niet overeenstemmen
Dit is waar de macOS-engine zijn complexiteit rechtvaardigt. Drie opnamebronnen, drie hardwareklokken, drie verschillende ideeën over hoe laat het is.
Beide audiobronnen krijgen een tijdstempel en worden omgezet naar een globale frame-index. De mixer leegt beide wachtrijen synchroon en produceert alleen output wanneer beide voldoende data hebben. Als één bron stokt (gedempte microfoon, bevroren virtueel apparaat), detecteert de mixer dit na 500ms en schakelt over naar de modus voor één enkele bron totdat de andere bron weer actief is.
Daarnaast is er een subtieler probleem: audiodrivers die liegen over hun sample-rate. Sommige virtuele drivers rapporteren 48kHz, maar leveren buffers op 44,1kHz. Tijdens een vergadering van 30 minuten wordt deze drift hoorbaar. Onze oplossing is een correctie op basis van betrouwbaarheid: we meten de werkelijke buffer-cadans, en als deze consistent afwijkt van het gerapporteerde formaat over drie opeenvolgende buffers, interpreteren we de stream opnieuw op de juiste snelheid met een crossfade om klikken te voorkomen.
Op Windows wordt het grootste deel van deze complexiteit geabstraheerd door de interne mixer van de opname-engine. De afweging is controle: op macOS detecteren en herstellen we edge-cases zoals onjuiste drivers zelf; op Windows ruilen we die granulariteit in voor eenvoud.
Opnames die crashes overleven
Een reguliere MP4 schrijft de metadata pas aan het einde van het bestand. Als de app crasht voordat dat gebeurt, is de opname verloren. Op beide platforms gebruiken we daarom in plaats daarvan gefragmenteerde MP4 (fragmented MP4).
Elk segment is zelfvoorzienend. Een crash op minuut 30 betekent dat er hooguit de laatste seconde verloren gaat. Segmenten worden gelijktijdig naar lokale opslag en de cloud gestuurd. Als de netwerkverbinding wegvalt, blijven de segmenten lokaal bewaard en wordt de upload automatisch hervat zodra de connectiviteit hersteld is.
Desktop-opnames waren voorheen een van de meest voorkomende bronnen van supporttickets. Nu is het een saai onderdeel van de app dat gewoon werkt. De volledige rewrite is in twee maanden voltooid, en dat is te danken aan Atomic: zodra de bridge er was, betekende het toevoegen van een functie simpelweg het schrijven van Swift en het in realtime zien updaten van de UI.
Niet alles hoort in een renderproces thuis. Soms moet je native gaan.