
Phân Biệt Một Cuộc Gọi Với Một App Đang Mở
GeekBye có thể nhận ra bạn vừa vào một cuộc họp video và đề nghị ghi lại nó chỉ bằng một cú nhấp. Việc phát hiện hóa ra là nửa dễ — một Swift binary đọc tiêu đề cửa sổ mỗi mười giây. Nửa khó là độ chính xác: không phát hỏa khi Zoom chỉ đang mở, không nhắc cho một cuộc họp bạn đã đang ghi, và không tắt tiếng cái mic trong cuộc gọi bạn thực sự đang ngồi. Ba bản phát hành, và mỗi bản là một guard phải học cách không tự đánh bại chính mình.
GeekBye có một tính năng nghe thì tầm thường nhưng không hề: nó nhận ra khi nào bạn vừa vào một cuộc họp video và đề nghị, bằng một cú nhấp, bắt đầu ghi lại nó. Lời chào hàng là một câu duy nhất. Phần triển khai là ba bản phát hành của việc học ra rằng cái vấn đề thú vị chưa bao giờ là «phát hiện một cuộc họp» — mà là «đúng về nó». Đúng về chuyện bạn thực sự đang trong một cuộc gọi hay chỉ đang mở app. Đúng về chuyện đừng hỏi lần hai. Đúng về chuyện đừng tắt tiếng nhầm cái mic. Mỗi bản trong ba bản phát hành, ở cốt lõi, là một guard phải được dạy để không tự vấp phải chính mình.
Tiêu đề cửa sổ, không phải process
Quyết định đầu tiên và nặng ký nhất được viết vào một commit message như một tuyên bố thiết kế, không phải một chú thích cuối trang:
Detection uses window title matching (not process detection) to distinguish "app is open" from "in an active call."
Sự phân biệt đó chính là toàn bộ tính năng. Nếu bạn phát hiện cuộc họp bằng cách nhìn xem app nào đang chạy, bạn sẽ phát hỏa ngay khoảnh khắc Zoom khởi động — và Zoom mở suốt ngày trên rất nhiều desktop mà chẳng có một cuộc gọi nào. Nên GeekBye không canh chừng các process. Trên macOS nó chạy một Swift binary native nhỏ, MeetingMonitor, cứ mỗi mười giây gọi CGWindowListCopyWindowInfo cho các cửa sổ trên màn hình và đọc tiêu đề của từng cái qua kCGWindowName. Một cửa sổ có tiêu đề "Zoom Meeting" nghĩa là bạn đang trong một cuộc gọi; cửa sổ "Zoom Workplace" nghĩa là app chỉ đang mở. Tín hiệu không phải «Zoom còn sống không», mà là «một cửa sổ trên màn hình của bạn trông có giống một cuộc gọi đang diễn ra không».
Khoảng poll là một hằng số cứng — pollIntervalSeconds: Double = 10.0 — cộng một lần kiểm tra tức thì lúc startup, nên một cuộc họp được nhận ra trong khoảng mười giây kể từ khi cửa sổ của nó xuất hiện. (Đó là một giá trị trong code, không phải một độ trễ đo được; hãy coi «tối đa ~10 giây» là phép tính, không phải một benchmark.)
Các cuộc họp trên browser cần thêm một bước, vì một cửa sổ Chrome có tiêu đề "Meet - Weekly Sync" thì hứa hẹn nhưng sự chắc chắn nằm trong URL. Nên khi app trên cùng là một browser đã biết — Chrome, Safari, Edge, Brave, và một nhúm nữa được nhận diện bằng bundle id — binary rút URL của tab đang hoạt động qua AppleScript và đối chiếu nó với các pattern như meet.google.com/, zoom.us/j/, và teams.microsoft.com/. AppleScript vào một app khác có thể treo, nên lời gọi đó chạy trên một background DispatchQueue được canh bởi một DispatchSemaphore với timeout 3 giây; một browser bị kẹt sẽ timeout thay vì đóng băng cả detector.
Phần khó là không phát hỏa
Đây là phần làm tôi bất ngờ khi đọc danh sách pattern. Phát hiện một cuộc gọi là vài lần khớp chuỗi. Không phát hiện một cuộc gọi — không phát hỏa khi bạn không muốn nó phát — mới là nơi mọi sự chăm chút đổ vào. Danh sách các pattern tiêu-đề-cửa-sổ đọc lên như một bộ những loại trừ có chủ đích:
- Google Meet khớp
"Meet - "và biến thể en-dash của nó"Meet – ", nhưng không khớp chuỗi trơ trọi"Google Meet"— vì đó là trang landing bạn mở trước một cuộc gọi. - Zoom khớp
"Zoom Meeting"và"Zoom Webinar", nhưng không khớp"Zoom Workplace"hay một"Zoom"trơ trọi — cái app đang ngồi mở đó. - Teams khớp
"Meeting with","Meeting in"," | Call"," | Meeting"— các hình dạng tiêu đề khi-đang-gọi, không phải cái client đang rảnh. - Slack huddle, Webex room, và WhatsApp call, mỗi thứ nhận những mảnh chỉ-khi-đang-gọi của riêng nó.
Mỗi cái trong đó là một quyết định giữ im lặng trong tình huống thường gặp khi một app đang mở nhưng bạn không ở trong một cuộc họp. Mục tiêu thiết kế không phải recall tối đa; mà là không trở thành cái công cụ bật lên một banner «muốn ghi không?» trong lúc bạn đang đọc Slack. Một false positive ở đây đắt theo cách mà một false negative thì không: bỏ lỡ một cuộc gọi thì người dùng bắt đầu ghi thủ công; phát hỏa nhầm trên một app đang mở thì người dùng học cách phớt lờ banner của bạn. Cả bộ heuristic được tinh chỉnh về phía làm cho loại thất bại thứ hai trở nên hiếm.
Việc nêu ra giới hạn của một heuristic đơn giản đến thế này là trung thực. Các pattern chỉ dành cho tiếng Anh — một OS đã bản địa hóa hay một app họp không phải tiếng Anh sẽ không khớp, một giới hạn v1 được thừa nhận. Và vì tiêu đề cửa sổ của các app khác chỉ thấy được khi đã cấp quyền Screen Recording, việc phát hiện lặng lẽ thoái hóa về con số không nếu thiếu nó. Đơn giản, dễ đọc, và bất toàn theo những cách mà nhóm đã viết ra thay vì giấu đi.
Lời đề nghị, và bộ máy giữ nó khỏi cằn nhằn
Khi một pattern khớp, GeekBye hiện một banner nhỏ không viền luôn-trên-cùng ở góc — cửa sổ của riêng nó, không phải một notification của OS — với một nút Start Recording. Nhấp nó thì một IPC message (meeting:start-recording) giấu banner đi, gọi cửa sổ chính lên, và bảo renderer bắt đầu một session. Bỏ qua nó thì banner biến mất.
Nhưng một phiên bản ngây thơ của việc này sẽ phát điên. Tiêu đề đổi liên tục trong một cuộc gọi — một người tham gia vào, bạn đổi tên cuộc họp, một lần chia sẻ màn hình thay tên cửa sổ — và mỗi thay đổi sẽ khiến phát hỏa lại một detector lấy khóa là đúng cái chuỗi ấy. Nên detector, trong MeetingDetector.ts, mang theo một state machine nhỏ mà toàn bộ công việc của nó là sự kiềm chế:
- Một
meetingIdổn định theo từng nền tảng. Phía Swift phát ra một id nhưgoogle-meet-active, cố ý không dẫn xuất từ cái tiêu đề cửa sổ hay thay đổi, để việc vào, ra, và đổi tên trong một cuộc gọi không bị đọc thành một cuộc họp mới. - Một
dismissedMeetings: Set<string>. Bỏ qua banner thì id của cuộc họp đó bị phớt lờ — cho tới khi cuộc họp thật sự kết thúc, lúc đó id được xóa để cuộc họp thật kế tiếp có thể nhắc lại từ đầu. - Một boolean
notificationActive. Mỗi lần chỉ một banner, để một Slack huddle bật lên trong lúc bạn đã đang ở trong một Meet không chồng thêm một lời nhắc thứ hai.
Đó mới thật sự là tính năng này: một chút phát hiện và rất nhiều «đừng gây phiền». Chính là lý do vì sao hai bản phát hành tiếp theo tồn tại — vì hai trong số những guard đó hóa ra tự đánh bại chính mình.
Cú tắt tiếng đã tắt tiếng cuộc họp thật của bạn (v1.8.18)
Việc ghi có một nút tắt tiếng, và bản triển khai đầu tiên hiểu «tắt tiếng» theo nghĩa đen nhất của nó. Ship trong v1.8.15, nó tắt tiếng mic bằng cách đặt input volume của hệ điều hành về không:
osascript -e "set volume input volume 0"
với volume trước đó được lưu vào cơ sở dữ liệu để có thể khôi phục, một tương đương phía Windows qua waveInSetVolume, và một lưới an toàn before-quit để chắc chắn nó đã bỏ tắt tiếng. Code thậm chí mô tả nó như một tính năng — tắt tiếng «ở HAI cấp», hệ thống và app, để mic bị chặn cho mọi app.
Cụm cuối cùng đó chính là con bug được phát biểu như một lời khoe. Chặn mic cho mọi app nghĩa là chặn nó cho cái cuộc gọi Zoom hay Meet mà bạn thực sự đang ngồi. Bạn nhấn tắt tiếng trong GeekBye để ngăn nó nghe bạn, và bạn đã lặng thinh trong cuộc họp thật của mình. Bản sửa v1.8.18 là một cú giáng chức: thôi hẳn việc đụng vào input volume của OS, và chỉ tắt tiếng audio pipeline của riêng GeekBye. Trong Listen mode, RealtimeAudioCaptureService thay các frame mic bị tắt tiếng bằng một buffer không để sự im lặng chảy tới mixer; trong video mode, ScreenRecordingService đặt một GainNode của Web Audio về không. Cú tắt tiếng toàn hệ thống, việc khôi phục volume đã lưu, cả bộ máy đa nền tảng — xóa hết. Tắt tiếng phải nghĩa là «app này ngừng nghe», chứ không bao giờ là «mic của bạn tắt cho tất cả mọi người».
Cái guard đã tự reset chính mình (v1.8.19)
Cái guard tự đánh bại mình còn lại là notificationActive. Trên giấy nó đã trả lời «đừng hiện một banner nếu đã có một cái đang bật» — và trông có vẻ nó cũng nên lo luôn «đừng đề nghị ghi một cuộc họp tôi đã đang ghi». Nó đã không, và commit sửa nó giải thích chính xác vì sao:
The notificationActive flag resets when the meeting detector cycles through ended/detected states, causing notifications to reappear mid-recording.
Detector là một lần poll sống; suốt một cuộc gọi dài nó có thể đọc thoáng qua «ended» rồi lại «detected» — một cú nhấp nháy tiêu đề, một cửa sổ ra khỏi màn hình trong khoảnh khắc — và mỗi vòng lại reset cái flag. Nên guard cứ lặng lẽ lên đạn lại cho chính nó trong lúc bạn đang ghi, và banner sẽ hiện lại đề nghị ghi cái cuộc gọi bạn đã đang ghi. Bản sửa thôi tin một flag cục bộ và hỏi nguồn sự thật thay vào đó: một callback recordingActiveChecker, được tiêm từ main.ts và đấu dây tới trạng thái isRealtimeTranscriptionActive thật, được kiểm tra trước khi bất kỳ notification nào hiện.
// Don't show notification if recording/transcription is already active
if (this.recordingActiveChecker?.()) {
return
}
Bài học nhỏ và tổng quát: một guard mà trạng thái do bạn sở hữu có thể bị reset ngay dưới chân bạn bởi chính luồng điều khiển của bạn. Một guard đọc chính cái trạng thái thật mà nó đang bảo vệ thì không thể. notificationActive đang theo dõi một proxy cho «chúng ta có đang ghi không»; bản sửa hỏi thẳng chúng ta có đang ghi hay không.
Ba điều những bản phát hành này dạy chúng tôi
- Hãy phát hiện cái trạng thái bạn muốn nói, không phải cái dễ thấy. «App đang chạy» thì kiểm tra dễ ợt và sai. «Tồn tại một cửa sổ trông giống một cuộc gọi đang diễn ra» thì khó hơn một chút và đúng. Khoảng cách giữa hai cái đó là toàn bộ tính năng.
- Với một tính năng chủ động, độ chính xác chính là sản phẩm. Không ai nhớ cuộc họp bạn phát hiện đúng; họ nhớ ba lần bạn ngắt ngang họ khi họ chẳng ở trong cuộc nào. Các ngoại lệ pattern và cái state machine chống-cằn-nhằn không phải là đánh bóng — chúng là sự khác biệt giữa một banner hữu ích và một banner bạn tự luyện mình phớt lờ.
- Một guard có thể tự reset thì không phải là guard. Cả hai bản phát hành tiếp theo cùng một hình dạng: một cơ chế an toàn tham chiếu tới một proxy nó tự kiểm soát (một volume đã lưu, một boolean cục bộ) thay vì trạng thái thật (mic của OS, trạng thái transcription). Hãy chĩa guard vào sự thật, không phải vào một bản sao của nó.
Về chương trước trong câu chuyện v1, xem Gỡ Backend Ra Khỏi Đường Đi Của Upload (v1.8.11–v1.8.13); và cho toàn bộ vòng cung, xem giải phẫu việc xuất xưởng phần mềm đến độ hoàn hảo.