プログラミング
Electron会議録音エンジンをSwiftで再構築する方法
Rebuilding our Electron meeting-recording engine in Swift (circleback.ai)
要約
CircleBack社は、Electronアプリで実行されていた会議録音エンジンをネイティブのSwiftコードに移行しました。Electronのレンダリングプロセスは、リアルタイムオーディオ・ビデオキャプチャにはGCポーズやスロットリングなどの問題があり、信頼性の低下を招いていました。macOSではScreenCaptureKit、Windowsではlibobsを使用し、これらをSwiftで統合するアプローチを採用しました。また、Swiftの@PublishedプロパティをReactのJotaiアトムに自動的に変換する内部ツール「Atomic」を開発し、プラットフォーム間のブリッジコードを大幅に削減しました。
全文翻訳
エンジニアリング
Electron録音エンジンをSwiftで再構築する方法
Arthur Guiot
2026年4月16日
私たちのデスクトップアプリは、ボットなしで会議をキャプチャし、クラウドにストリーミングします。数ヶ月間、録音エンジンは製品の中で最も信頼性の確保が難しい部分でした。あるクラスのエッジケースを修正して出荷すると、翌週には新しいエッジケースが現れました。根本原因は異なっても、パターンは同じでした。エンジンはElectronアプリのレンダリングプロセスで実行されていました。私たちは明らかな修正を試みました:ライフサイクル管理の強化、メインスレッドからの作業のオフロード、Reactのレンダリングサイクルからの分離。それぞれの変更はわずかに役立ちましたが、根本的な問題には対処できませんでした。レンダリングプロセスは、リアルタイムのオーディオとビデオをキャプチャする場所としては不適切です。キャプチャエンジンは、ブラウザランタイムが応答性を維持するために行うGCポーズ、スロットリング、その他のものに耐えられません。そこで、私たちはネイティブに移行しました:macOSではScreenCaptureKit、Windowsではlibobsを使用し、それらを連携させる共有Swiftレイヤーを構築しました。
Atomic:CombineからJotaiへのブリッジ
ネイティブランタイムをReactにブリッジするには、通常、ネイティブアドオンバインディングを手動で記述する必要があります。境界を越えるすべての値をシリアライズし、文字列型の名前を介してイベントをルーティングし、プロパティを追加するたびに3つのファイルを更新します:Swiftクラス、C++バインディング、TypeScriptラッパー。これは機能しますが、誰かが手順を忘れた瞬間に同期が取れなくなります。Swiftのすべての@PublishedプロパティがReactのJotaiアトムに自動的に変換されたらどうなるでしょうか?完全にリアクティブで、型安全で、グルーコードなし。それが私たちの内部ツールAtomicが行うことです。
@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); // Swiftに流れます。
player.play(); // Reactにフィードバックが流れます。
Reactの観点からは、これらのアトムは他のJotaiアトムと区別がつきません。データが別のスレッド上のSwiftランタイムに存在するという事実は不可視です。@NodeExportマクロは、コンパイル時にブリッジ全体を生成します。型は自動的にマッピングされます(Int → number、String? → string | null)。Swiftでの値の変更は、Nodeのイベントループでコールバックをスケジュールします。Swift側で追加する新しいプロパティはすべて、Reactで即座に利用可能になります。そして、AtomicはAppleフレームワーク(WindowsではOpenCombineを使用)ではなくSwift上に構築されているため、同じブリッジが両方のプラットフォームで実行されます。
2つのキャプチャエンジン、1つのインターフェース
macOSでは、ScreenCaptureKitがハードウェアアクセラレーションされたキャプチャとネイティブコンテンツピッカーを提供します。Windowsでは、OBSKitと呼ぶSwiftラッパーを介してlibobsを使用します。2つのエンジンは根本的に異なるアーキテクチャを持っています。macOSでは、3つの独立したソースから生のサンプルバッファを受信し、ファイル自体を組み立てます。Windowsでは、キャプチャ、ミキシング、エンコーディング、ミキシングが単一のグラフとして実行されます。それを設定すると、ファイルモニターが新しく書き込まれたバイトをアップロードセッションにストリーミングします。Windowsのキャプチャには独自の課題があります。プライマリメソッドとしてWindows Graphics Capture(WGC)を使用し、フレームが時間通りに配信されない場合はBitBltにフォールバックします。また、すべての黒いフレーム(一部のエミュレートされたウィンドウやゲームで一般的)を検出し、録音中にメソッドを切り替えます。
クロックが一致しない場合
ここでmacOSエンジンはその複雑さを発揮します。3つのキャプチャソース、3つのハードウェアクロック、3つの異なる時間の概念。両方のオーディオソースはタイムスタンプされ、グローバルフレームインデックスに変換されます。ミキサーは両方のキューを同期してドレインし、両方に十分なデータがある場合にのみ出力を生成します。一方のソースが停止した場合(マイクがミュートされた、仮想デバイスがフリーズした)、ミキサーは500ms後にそれを検出し、再開するまでシングルソースモードに切り替えます。さらに、微妙な問題があります:サンプルレートについて嘘をつくオーディオドライバー。一部の仮想ドライバーは48kHzを報告しますが、バッファを44.1kHzで配信します。30分間の会議では、このドリフトは聴覚可能になります。私たちの修正は、信頼性ベースの補正です:実際のバッファケイデンスを測定し、それが3つの連続するバッファにわたって報告されたフォーマットと一貫して矛盾する場合、クリックを回避するためにクロスフェードを使用して正しいレートでストリームを再解釈します。Windowsでは、この複雑さのほとんどはキャプチャエンジンの内部ミキサーによって抽象化されています。トレードオフは制御です:macOSでは、嘘をつくドライバーのようなエッジケースを自分で検出して修正します。Windowsでは、その粒度を単純さのために交換します。
クラッシュを乗り越える録音
通常のMP4はファイルの最後にメタデータを書き込みます。その前にクラッシュすると、録音は失われます。両方のプラットフォームで、フラグメント化されたMP4を使用します。各セグメントは自己完結型です。30分でのクラッシュは、最後の1秒を失うだけです。セグメントはローカルストレージとクラウドの両方に同時に送信されます。ネットワークがドロップした場合、セグメントはローカルに永続化され、接続が回復するとアップロードは自動的に再開されます。
デスクトップ録音は、かつてはサポートチケットの最も一般的なソースの1つでした。今では、アプリの退屈な部分であり、ただ機能します。全体的な書き換えは2ヶ月で出荷され、Atomicがその理由です:ブリッジが存在すると、機能を追加することはSwiftを書いてUIがリアルタイムで更新されるのを見ることを意味しました。すべてをレンダリングプロセスに含めるべきではありません。時にはネイティブに移行する必要があります。
このような問題に取り組むことに興味があるなら、私たちに参加することを検討してください。