Tái xây dựng engine ghi âm cuộc họp Electron bằng Swift
Bài viết chia sẻ chi tiết quá trình Circleback chuyển engine ghi âm cuộc họp từ Electron render process sang native Swift, sử dụng ScreenCaptureKit trên macOS và libobs trên Windows. Điểm nhấn là công cụ Atomic giúp tự động kết nối Swift và React thông qua Jotai, cùng các giải pháp xử lý đồng bộ đồng hồ và ghi âm chống mất dữ liệu khi crash.

Tái xây dựng engine ghi âm cuộc họp Electron bằng Swift
Ứng dụng desktop của Circleback ghi lại cuộc họp mà không cần bot và phát trực tiếp lên đám mây. Trong nhiều tháng, engine ghi âm là phần khó nhất để làm cho đáng tin cậy. Sau khi chuyển sang native Swift với ScreenCaptureKit trên macOS và libobs trên Windows, họ đã xây dựng Atomic — một cầu nối tự động biến mọi thuộc tính trong Swift thành các Jotai atom trong React, giúp quá trình phát triển nhanh hơn đáng kể và loại bỏ hoàn toàn các vấn đề về độ ổn định.
Vấn đề với render process
Engine ghi âm ban đầu chạy trong render process của Electron. Nhóm đã thử các giải pháp thông thường như quản lý lifecycle chặt chẽ hơn, chuyển công việc ra khỏi main thread, cô lập với vòng lặp render của React. Mỗi thay đổi chỉ cải thiện một chút nhưng không giải quyết được gốc rễ.
A capture engine can't tolerate GC pauses, throttling, or any of the other things a browser runtime does to stay responsive.
Một render process không phải là nơi phù hợp để xử lý ghi âm video/audio realtime. Các vấn đề như GC pauses hay throttling từ trình duyệt khiến engine hoạt động không ổn định. Vì vậy, họ quyết định chuyển sang native: ScreenCaptureKit trên macOS, libobs trên Windows, và một lớp Swift dùng chung kết nối lại.
Atomic: Cầu nối Combine-to-Jotai
Thông thường, kết nối native runtime với React đòi hỏi viết bindings thủ công — serializing mọi giá trị qua biên giới, đặt tên event theo chuỗi, và cập nhật ba file mỗi khi thêm thuộc tính: class Swift, binding C++, và wrapper TypeScript. Điều này dễ mất đồng bộ chỉ cần quên một bước.
Atomic giải quyết điều đó: mọi thuộc tính @Published trong Swift tự động trở thành Jotai atom trong React, hoàn toàn reactive và type-safe, không cần glue code.
@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(player.volume);
store.set(volumeAtom, 0.5); // Flows into Swift.
player.play(); // Updates flow back into React.
Từ góc nhìn React, các atom này giống hệt bất kỳ Jotai atom nào khác — không thể nhận ra dữ liệu đang sống trong Swift runtime ở thread khác.
Macro @NodeExport tạo toàn bộ bridge tại thời điểm biên dịch. Các kiểu dữ liệu tự động ánh xạ (Int → number, String? → string | null). Thay đổi giá trị trong Swift lên lịch callbacks trên Node's event loop. Mỗi thuộc tính thêm ở phía Swift lập tức có sẵn trong React. Vì Atomic dựa trên Swift thuần (dùng OpenCombine trên Windows), cùng một bridge chạy được trên cả hai nền tảng.
Sơ đồ cầu nối Atomic giữa Swift và React
Hai capture engines, một interface
Trên macOS, ScreenCaptureKit cung cấp khả năng ghi hình tăng tốc phần cứng và content picker gốc. Trên Windows, họ dùng libobs qua một Swift wrapper gọi là OBSKit. Kiến trúc của hai engine khác nhau cơ bản:
- macOS: Nhận raw sample buffers từ ba nguồn độc lập rồi tự lắp ráp thành file.
- Windows: Capture, mixing, encoding và muxing chạy trong một graph duy nhất. Họ cấu hình và một file monitor stream các bytes mới ghi vào upload session.
Windows có những thách thức riêng: dùng Windows Graphics Capture (WGC) làm phương pháp chính, nếu không giao khung hình đúng hạn thì fallback sang BitBlt. Họ cũng phát hiện các khung hình đen hoàn toàn (thường gặp với cửa sổ giả lập hoặc game) và đổi phương pháp giữa chừng.
So sánh kiến trúc capture engines macOS và Windows
Khi đồng hồ không khớp nhau
Đây là nơi engine macOS thể hiện độ phức tạp. Ba nguồn capture, ba hardware clocks, ba quan niệm khác nhau về thời gian. Cả hai nguồn audio được gắn timestamp và chuyển đổi sang một global frame index. Mixer xử lý cả hai hàng đợi song song, chỉ tạo output khi cả hai đủ dữ liệu. Nếu một nguồn dừng (mic bị tắt tiếng, virtual device đóng băng), mixer phát hiện sau 500ms và chuyển sang chế độ một nguồn đến khi nguồn kia hoạt động lại.
Vấn đề tinh vi hơn: một số audio driver khai báo sai sample rate. Một số virtual drivers báo 48kHz nhưng thực tế giao buffers ở 44.1kHz. Trong cuộc họp kéo dài 30 phút, độ lệch này trở nên nghe được. Giải pháp của họ là confidence-based correction: đo nhịp buffer thực tế, nếu nó liên tục không khớp với định dạng khai báo qua ba buffers liên tiếp thì diễn giải lại stream ở tốc độ đúng với crossfade để tránh tiếng click.
Sơ đồ đồng bộ ba nguồn thời gian
Trên Windows, phần lớn độ phức tạp này được capture engine's internal mixer hấp thụ. Đánh đổi là quyền kiểm soát: trên macOS họ tự phát hiện và sửa các edge case như driver nói dối; trên Windows họ đánh đổi chi tiết để lấy sự đơn giản.
Bản ghi âm sống sót qua sự cố crash
MP4 thông thường ghi metadata ở cuối file. Nếu crash trước khi hoàn tất, bản ghi sẽ mất toàn bộ. Trên cả hai nền tảng, họ dùng fragmented MP4 — mỗi phân đoạn tự chứa đủ thông tin. Nếu crash ở phút 30, mất tối đa một giây cuối cùng.
Các phân đoạn được ghi đồng thời vào local storage và đám mây. Nếu mạng rớt, phân đoạn vẫn được lưu cục bộ và upload tự động tiếp tục khi kết nối khôi phục.
Cấu trúc fragmented MP4 với nhiều phân đoạn độc lập
Kết quả
Desktop recording từng là một trong những nguồn ticket hỗ trợ lớn nhất. Giờ nó chỉ là một phần "nhàm chán" của ứng dụng hoạt động ổn định. Toàn bộ quá trình viết lại hoàn thành trong hai tháng, và Atomic chính là chìa khóa: một khi bridge tồn tại, thêm tính năng chỉ là viết Swift và quan sát UI cập nhật theo thời gian thực.
Not everything belongs in a render process. Sometimes you need to go native.
Với các nhà phát triển đang gặp vấn đề tương tự với Electron, bài học lớn nhất là: nếu ứng dụng của bạn có các tác vụ realtime quan trọng, đừng ép chúng sống trong môi trường JavaScript runtime. Đầu tư vào native layer ngay từ đầu có thể tiết kiệm rất nhiều thời gian xử lý sự cố về sau.