Steven
Steven2 分钟阅读

把一场通话和一个开着的 app 分辨开

GeekBye 能注意到你加入了一场视频会议,并提出替你一键录下它。检测原来是容易的那一半——一个每十秒读一遍窗口标题的 Swift 二进制。难的那一半是精确度:Zoom 只是开着时不发火,不为一场你已经在录的会议来提示你,也不去静音你真正身处的那场通话里的麦克风。三次发布,而每一次都是一个不得不学会不打败自己的守卫。

工程
macOS
桌面端
GeekBye 发布
把一场通话和一个开着的 app 分辨开

GeekBye 有一个听起来微不足道、实则不然的功能:它注意到你何时加入了一场视频会议,并提出,一键,替你开始录下它。卖点是一句话。实现则是三次发布,学到那个有意思的问题从来不是「检测一场会议」——而是「对它拿得准」。拿得准你到底是在一场通话里,还是只是开着这个 app。拿得准别问第二遍。拿得准别静音了错的麦克风。这三次发布里的每一次,说到底,都是一个不得不被教会别绊倒自己的守卫。

窗口标题,而非进程

第一个、也是最有分量的决定,是作为一句设计声明、而非一条脚注,写进了一条 commit 信息里:

Detection uses window title matching (not process detection) to distinguish "app is open" from "in an active call."

那个区分就是整个功能。假如你靠看哪些 app 正在_运行_来检测会议,你会在 Zoom 一启动的那一刻就发火——而 Zoom 在很多桌面上整天开着,却一场通话也没有。所以 GeekBye 不盯着进程。在 macOS 上它跑一个小的原生 Swift 二进制 MeetingMonitor,它每十秒对屏上的窗口调一次 CGWindowListCopyWindowInfo,并通过 kCGWindowName 读取每一个的标题。一个标题为 "Zoom Meeting" 的窗口意味着你在一场通话里;那个 "Zoom Workplace" 窗口意味着 app 只是开着。信号不是「Zoom 活着没」,而是「你屏幕上的一个窗口看起来像不像一场活的通话」。

轮询间隔是一个硬常量——pollIntervalSeconds: Double = 10.0——外加启动时一次立即检查,所以一场会议会在它的窗口出现后大约十秒内被注意到。(那是一个代码里的值,不是一次测出来的延迟;把「至多 ~10 秒」当作算术,而非一次基准测试。)

浏览器会议还需要一步,因为一个标题为 "Meet - Weekly Sync" 的 Chrome 窗口很有希望,但确定性在 URL 里。所以当最前面的 app 是一个已知浏览器——Chrome、Safari、Edge、Brave,以及靠 bundle id 认出的另外一小把——时,这个二进制通过 AppleScript 抽出活动标签页的 URL,再拿它对着 meet.google.com/zoom.us/j/teams.microsoft.com/ 这样的模式去核对。往另一个 app 里发 AppleScript 可能卡住,所以那个调用跑在一个后台 DispatchQueue 上,由一个带 3 秒超时DispatchSemaphore 守着;一个楔住了的浏览器会超时,而不是把整个检测器冻住。

难的部分是不发火

我读那份模式清单时让我意外的部分在这里。检测一场通话是几次字符串匹配。_不_检测一场——在你不想它发火时不发火——才是所有心思去到的地方。那份窗口标题模式的清单读起来像一组刻意的排除:

  • Google Meet 匹配 "Meet - " 及其 en-dash 变体 "Meet – ",但匹配那个光秃秃的字符串 "Google Meet"——因为那是你在通话之前打开的落地页。
  • Zoom 匹配 "Zoom Meeting""Zoom Webinar",但匹配 "Zoom Workplace" 或一个光秃秃的 "Zoom"——那是坐着开着的 app。
  • Teams 匹配 "Meeting with""Meeting in"" | Call"" | Meeting"——通话中的标题形状,而非闲着的客户端。
  • Slack huddle、Webex room 和 WhatsApp 通话,各自得到它们自己的、只在通话中出现的片段。

它们每一个都是一个决定:在一个 app 开着但你并不在会议里的常见情形下保持安静。设计目标不是最大的召回率;而是不做那个在你读 Slack 时弹出一个「要录吗?」banner 的工具。这里的一次误报,代价高得和一次漏报不一样:错过一场通话,用户就手动开始录;在一个开着的 app 上误发火,用户就学会无视你的 banner。整套启发式都被调向让第二种失败变得罕见。

诚实地点出这么简单的一套启发式的局限是应该的。这些模式只认英语——一个本地化的 OS 或一个非英语的会议 app 不会匹配,一个被承认的 v1 局限。而且因为别的 app 的窗口标题只有在被授予屏幕录制权限时才可见,没有它,检测就悄悄退化到什么都没有。简单、可读,且以团队写下来而非藏起来的方式,不完美。

那次提出,以及拦着它别唠叨的机件

一旦一个模式匹配,GeekBye 在角落里显示一个小的无边框常驻置顶 banner——它自己的窗口,不是一个 OS 通知——带一个 Start Recording 按钮。点它,一条 IPC 消息(meeting:start-recording)就隐藏 banner、唤起主窗口,并告诉渲染进程开始一段会话。忽略它,banner 就走开。

但这个的一个天真版本会让人抓狂。标题在一场通话里不停地变——一个参与者加入、你给会议改名、一次屏幕共享换掉了窗口名——而每一次变化都会重新点燃一个以那个确切字符串为键的检测器。所以 MeetingDetector.ts 里的那个检测器,带着一台小状态机,它整份差事就是克制:

  • 一个每平台稳定的 meetingId Swift 那边发出一个像 google-meet-active 的 id,刻意_不_从那个善变的窗口标题里推导,这样一场通话之内的加入、离开和改名不会被读成一场新会议。
  • 一个 dismissedMeetings: Set<string> 忽略这个 banner,那场会议的 id 就被无视——直到会议真正结束,那一刻 id 清空,好让_下一场_真会议能重新提示。
  • 一个 notificationActive 布尔值。 一次只有一个 banner,这样在你已经在一场 Meet 里时冒出来的一个 Slack huddle,不会摞上第二个提示。

那才真的是这个功能:一点点检测,和很多「别惹人烦」。这正是接下来那两次发布存在的缘由——因为那些守卫里有两个,结果打败了它们自己。

那个把你真实会议静了音的静音 (v1.8.18)

录制有一个静音按钮,而第一版实现把「静音」当成了它最字面的意思。在 v1.8.15 发出,它靠把操作系统的输入音量设成零来静音麦克风:

osascript -e "set volume input volume 0"

之前的音量被存进数据库好能恢复,一个通过 waveInSetVolume 的 Windows 等价物,还有一张 before-quit 的安全网确保它取消了静音。代码甚至把它描述成一个功能——「在两个层面上」静音,系统和 app,好让麦克风对_所有_ app 都被挡住。

那最后一句话就是被当作夸耀说出来的 bug。为所有 app 挡住麦克风,意味着为你实际正坐在里面的那场 Zoom 或 Meet 通话挡住它。你在 GeekBye 里按静音,是要让_它_停止听你,你却在你真实的会议里失了声。v1.8.18 的修复是一次降级:彻底不再碰 OS 的输入音量,只静音 GeekBye 自己的音频管线。在 Listen 模式里,RealtimeAudioCaptureService 用一个零缓冲替换掉被静音的麦克风帧,好让静默流向混音器;在视频模式里,ScreenRecordingService 把一个 Web Audio 的 GainNode 设成零。那个系统全局的静音、那次存音量的恢复、那整套跨平台的机关——全删了。静音应当意味着「这个 app 停止听」,绝不该是「你的麦克风对所有人关掉」。

那个重置了自己的守卫 (v1.8.19)

另一个打败自己的守卫是 notificationActive。纸面上它已经回答了「如果有一个 banner 已经在,就别再显示 banner」——而它看起来也应当罩得住「别提出去录一场我已经在录的会议」。它没有,而修好它的那条 commit 精确地解释了为什么:

The notificationActive flag resets when the meeting detector cycles through ended/detected states, causing notifications to reappear mid-recording.

这个检测器是一次活的轮询;在一场长通话里,它可能短暂地读到「ended」再读到「detected」——一次标题闪动、一个瞬间离屏的窗口——而每一次循环都重置了那个标志。于是这个守卫在你录制时悄悄地一次次给自己重新上膛,而 banner 会再次出现,提出去录那场你已经在录的通话。修复不再信任一个本地标志,而是去问真相之源:一个 recordingActiveChecker 回调,从 main.ts 注入并接到真实的 isRealtimeTranscriptionActive 状态上,在任何通知显示之前被检查。

// Don't show notification if recording/transcription is already active
if (this.recordingActiveChecker?.()) {
  return
}

教训又小又通用:一个状态归你所有的守卫,能被你自己的控制流从脚下重置掉。一个去读它所保护的实际状态的守卫,不能。notificationActive 追踪的是「我们在不在录制」的一个_代理_;修复问的是我们在不在录制。

这些发布教给我们的三件事

  1. 去检测你所指的那个状态,而不是那个容易看见的。 「app 在运行」查起来微不足道,而且是错的。「存在一个看起来像活通话的窗口」难一点点,而且是对的。这两者之间的缝隙就是整个功能。
  2. 对一个主动出击的功能而言,精确度就是产品。 没人记得你正确检测到的那场会议;他们记得的是你在他们不在会里的时候打断他们的那三次。那些模式排除项和那台防唠叨的状态机不是打磨——它们是一个有用的 banner,和一个你把自己训练到会无视的 banner,之间的区别。
  3. 一个能重置自己的守卫不是守卫。 两次跟进发布是同一个形状:一个安全机制去引用它自己控制的一个代理(一个存下的音量、一个本地布尔值),而不是那个真实的状态(系统麦克风、转录状态)。把守卫指向真相,而不是指向它的一个副本。

关于 v1 故事的上一章,见把后端从上传路径里拿掉(v1.8.11–v1.8.13);整段弧线则见把软件打磨到完美地发布出去的解剖学

相关文章

让 Web Audio 保持存活的三个动词
Steven
Steven2 分钟阅读

让 Web Audio 保持存活的三个动词

两次 GeekBye 小版本发布,相隔两个月、落在两个不同文件里,从相反的两端教给我们的音频代码同一个教训:别再把浏览器的 AudioContext 当成用完即弃的东西。一次发布学会了去 resume() 一个被 macOS 在录制中途悄悄 suspend 掉的 context;另一次学会了用 suspend() 而不是 close(),好让一场接一场的会话别再撞上 Chromium 那个大约六个 context 的天花板。resume、suspend、close——这就是全部剧情。

工程
Audio
桌面端
把后端从上传路径里拿掉
Steven
Steven3 分钟阅读

把后端从上传路径里拿掉

GeekBye 录下你的屏幕,并把视频存进你的 Google Drive。第一个版本在通往那里的路上,把每一段录制都经由 GeekBye 自己的服务器送过去;一次发布之后,文件从你的机器直奔 Drive,后端被降级成只握着一个指针。有意思的地方在于,那个「直接、可续传」的版本实际上包含的代码有多么少——因为可续传性来自删掉一个代理,而不是来自写一个。

工程
架构
桌面端
十二分钟里诞生,又被修好
Steven
Steven2 分钟阅读

十二分钟里诞生,又被修好

GeekBye v1.8.10 的 changelog 说它修好了一个编辑键盘快捷键时的崩溃。它确实修了——但那个崩溃是在同一个 pull request 里被引入又被修好的,前后相隔十二分钟,从没到达过哪怕一个用户。真正的故事是产生它的那场可靠性连锁反应:一个关于「何时挂起快捷键」的小而正确的改动,以及从一行本想让编辑器更安全的代码里掉出来的那个 React teardown bug。

工程
React
桌面端