Steven
Steven2 分で読める

開いているアプリと通話中を見分ける

GeekByeは、あなたがビデオ会議に参加したことに気づき、ワンクリックでの録画を差し出せます。検出のほうは、実のところ簡単な半分でした — 10秒ごとにウィンドウタイトルを読むSwiftバイナリです。難しい半分は精度です: Zoomがただ開いているだけのときに発火しないこと、すでに録画している会議についてうながさないこと、そしてあなたが実際にいる通話のマイクをミュートしないこと。三つのリリース、そのどれもが、自分自身を打ち負かさないことを学ばねばならなかったガードなのです。

エンジニアリング
macOS
デスクトップ
GeekByeリリース
開いているアプリと通話中を見分ける

GeekByeには、些細に聞こえて実はそうではない機能があります: あなたがビデオ会議に参加したことに気づき、ワンクリックでその録画を始めることを差し出すのです。売り文句はたった一文です。実装のほうは、面白い問題が決して「会議を検出する」ことではなかった — それは「それについて正しくある」ことだった、と学んだ三つのリリースです。あなたが本当に通話中なのか、それともただアプリを開いているだけなのかについて正しくあること。二度たずねないことについて正しくあること。間違ったマイクをミュートしないことについて正しくあること。三つのリリースのそれぞれは、心のところで、自分自身につまずかないよう教え込まねばならなかったガードなのです。

ウィンドウタイトル、プロセスではなく

最初の、そして最も重大な決定は、脚注としてではなく設計の宣言として、コミットメッセージに書き込まれています:

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

その区別こそが機能のすべてです。もしどのアプリが_実行中_かを見て会議を検出したら、Zoomが起動した瞬間に発火してしまうでしょう — そしてZoomは、一度も通話しないまま、多くのデスクトップで一日中開かれています。ですからGeekByeはプロセスを見張りません。macOSでは、小さなネイティブのSwiftバイナリMeetingMonitorを走らせ、それが10秒ごとに画面上のウィンドウについてCGWindowListCopyWindowInfoを呼び、kCGWindowNameを通じてそれぞれのタイトルを読みます。"Zoom Meeting"というタイトルのウィンドウは通話中であることを意味します; "Zoom Workplace"ウィンドウはアプリがただ開いているだけであることを意味します。シグナルは「Zoomが生きているか」ではなく、「あなたの画面上のウィンドウが、ライブの通話のように見えるか」なのです。

ポーリング間隔は固い定数 — pollIntervalSeconds: Double = 10.0 — に、起動時の一度の即時チェックを足したものなので、会議はそのウィンドウが現れてからおよそ10秒以内に気づかれます。(それはコード上の値であって、測定されたレイテンシではありません; 「最大〜10秒」は、ベンチマークではなく算数として受け取ってください。)

ブラウザ会議にはもう一段が要ります、なぜなら"Meet - Weekly Sync"というタイトルのChromeウィンドウは有望ですが、確かさはURLの中にあるからです。ですから最前面のアプリが既知のブラウザ — Chrome、Safari、Edge、Brave、そしてバンドルidで識別されるもう一握り — のとき、バイナリはAppleScriptを通じてアクティブなタブのURLを取り出し、それをmeet.google.com/zoom.us/j/teams.microsoft.com/のようなパターンに対して照合します。別のアプリへのAppleScriptはハングしうるので、その呼び出しは3秒のタイムアウトを持つDispatchSemaphoreで守られたバックグラウンドのDispatchQueueで走ります; 動かなくなったブラウザは、検出器全体を凍りつかせる代わりにタイムアウトします。

難しいのは発火しないこと

パターンリストを読んだとき、私を驚かせた部分がここです。通話を検出するのは、いくつかの文字列マッチです。通話を_検出しない_こと — あなたが望まないときに発火しないこと — こそが、すべての気配りが注がれた場所です。ウィンドウタイトルのパターンのリストは、意図的な除外の一式のように読めます:

  • Google Meetは"Meet - "と、そのenダッシュ版"Meet – "にマッチしますが、素の文字列"Google Meet"にはマッチしません — なぜならそれは、通話の前に開くランディングページだからです。
  • Zoomは"Zoom Meeting""Zoom Webinar"にマッチしますが、"Zoom Workplace"や素の"Zoom"にはマッチしません — 開いたまま座っているアプリです。
  • Teamsは"Meeting with""Meeting in"" | Call"" | Meeting"にマッチします — 通話中のタイトルの形であって、待機中のクライアントではありません。
  • Slackのハドル、Webexのルーム、そしてWhatsAppの通話は、それぞれ自分専用の通話中限定の断片を得ます。

それらのどれもが、アプリは開いているが会議中ではない、というよくある場合に静かでいるという決定です。設計目標は最大の再現率ではありません; それは、あなたがSlackを読んでいるあいだに「録画しますか?」のバナーをはじき出す道具にならないことです。ここでの誤検出は、見逃しがそうではないやり方で高くつきます: 通話を見逃せばユーザーは手動で録画を始めます; 開いたアプリで誤発火すれば、ユーザーはあなたのバナーを無視することを覚えます。ヒューリスティック全体は、二つ目の失敗がまれになるように調整されています。

これほど単純なヒューリスティックの限界を名指すのは誠実なことです。パターンは英語のみ — ローカライズされたOSや非英語の会議アプリはマッチしません、認められたv1の制限です。そして他のアプリのウィンドウタイトルは画面収録の許可が与えられているときにだけ見えるので、それがなければ検出は静かに何もないところへと退行します。単純で、読み取りやすく、そしてチームが隠すのではなく書き留めたやり方で不完全なのです。

差し出しと、それがしつこくならないよう保つ仕掛け

パターンがマッチすると、GeekByeは隅に小さなフレームなしの常時最前面のバナー — OSの通知ではなく、それ自身のウィンドウ — を、Start Recordingボタン付きで見せます。それをクリックするとIPCメッセージ(meeting:start-recording)がバナーを隠し、メインウィンドウを立ち上げ、レンダラーにセッションを始めるよう告げます。それを却下すればバナーは消えます。

しかしこれの素朴な版は、気が狂いそうになるものでしょう。タイトルは通話中に絶えず変わります — 参加者が加わり、あなたが会議の名前を変え、画面共有がウィンドウ名を入れ替える — そしてそのそれぞれの変化が、厳密な文字列をキーにした検出器を再発火させるでしょう。ですからMeetingDetector.tsの中の検出器は、その仕事のすべてが抑制であるような小さなステートマシンを携えています:

  • プラットフォームごとに安定したmeetingId Swift側はgoogle-meet-activeのようなidを発します、意図的に、移ろいやすいウィンドウタイトルから_導き出されない_もので、なので一つの通話の中での参加、退出、名前変更が、新しい会議として読まれることはありません。
  • dismissedMeetings: Set<string> バナーを却下すると、その会議のidは無視されます — 会議が実際に終わるまでは、そしてその時点でidはクリアされ、_次の_本当の会議が新たにうながせるようになります。
  • notificationActiveのブール値。 一度に一つのバナーだけなので、あなたがすでにMeetにいるあいだにSlackのハドルが飛び出しても、二つ目のうながしを積み重ねることはありません。

それが本当のところ、この機能です: 少しの検出と、たくさんの「うっとうしくならない」。それこそが、次の二つのリリースが存在する理由です — なぜなら、それらのガードのうち二つが、自分自身を打ち負かすと分かったからです。

実際の会議をミュートしてしまったミュート (v1.8.18)

録画にはミュートボタンがあり、最初の実装は「ミュート」を最も文字通りに受け取りました。v1.8.15で出荷されたそれは、オペレーティングシステムの入力音量をゼロに設定することでマイクをミュートしていました:

osascript -e "set volume input volume 0"

復元できるように以前の音量はデータベースに保存され、waveInSetVolumeを通じたWindows版の相当物があり、ミュート解除を確実にするためのbefore-quitの安全網がありました。コードはそれを機能としてすら記述していました — 「二つのレベルで」、システムとアプリで、ミュートすると、なのでマイクは_すべての_アプリに対してブロックされたのです。

その最後の言い回しは、自慢として述べられたバグです。すべてのアプリに対してマイクをブロックするとは、あなたが実際に座っているZoomやMeetの通話に対してそれをブロックするということです。GeekByeでミュートを押すのは_それ_があなたを聞くのを止めるためなのに、あなたは本当の会議で無言になってしまうのです。v1.8.18の修正は降格です: OSの入力音量に触れるのを完全にやめ、GeekBye自身のオーディオパイプラインだけをミュートします。Listenモードでは、RealtimeAudioCaptureServiceがミュートされたマイクのフレームをゼロバッファで置き換えるので、静寂がミキサーへ流れます; ビデオモードでは、ScreenRecordingServiceがWeb AudioのGainNodeをゼロに設定します。システム全体のミュート、保存した音量の回復、クロスプラットフォームの装置まるごと — すべて削除されました。ミュートは「このアプリが聞くのをやめる」を意味すべきで、決して「あなたのマイクが皆に対してオフになる」ではありません。

自分自身をリセットしたガード (v1.8.19)

もう一つの自分を打ち負かすガードはnotificationActiveでした。紙の上では、それはすでに「バナーが出ているなら別のバナーを見せるな」に答えていました — そしてそれは「すでに録画している会議を録画しようと差し出すな」もカバーするはずに見えました。そうではなく、それを直すコミットが理由を正確に説明します:

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

検出器はライブなポーリングです; 長い通話のあいだ、それは束の間「ended」と読み、それからまた「detected」と読むことがあります — タイトルのちらつき、一瞬画面外に出たウィンドウ — そしてそのそれぞれの循環がフラグをリセットしていました。ですからガードは、あなたが録画しているあいだに静かに自分を再武装し続け、バナーはあなたがすでに録画している通話を録画しようと差し出しながら、再び現れたのです。修正はローカルなフラグを信じるのをやめ、代わりに真実の源にたずねます: main.tsから注入され、本当のisRealtimeTranscriptionActive状態に配線されたrecordingActiveCheckerコールバックで、どの通知が見せられる前にも確認されます。

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

教訓は小さく、一般的です: あなたが状態を所有するガードは、あなた自身の制御フローによって足元からリセットされうる。それが守っている実際の状態を読むガードは、そうはなりません。notificationActiveは「録画中か」の_代理_を追跡していました; 修正は、私たちが録画中かどうかをたずねます。

これらのリリースが教えてくれた三つのこと

  1. 見やすい状態ではなく、あなたが意図する状態を検出せよ。 「アプリが実行中である」は確認するのが些細で、間違っています。「ライブの通話のように見えるウィンドウが存在する」は少し難しく、正しいのです。その二つのあいだの隔たりが、機能のすべてです。
  2. 先回りする機能にとって、精度こそが製品である。 あなたが正しく検出した会議を、誰も覚えていません; 彼らが覚えているのは、彼らが会議中でないときにあなたが邪魔した三度です。パターンの除外としつこくしないためのステートマシンは、磨き上げではありません — それらは、役に立つバナーと、あなたが自分を訓練して無視するようになるバナーとの違いなのです。
  3. 自分自身をリセットできるガードは、ガードではない。 二つの後続リリースは同じ形をしています: 実際の状態(OSのマイク、文字起こしの状態)の代わりに、自分が制御する代理(保存した音量、ローカルなブール値)を参照した安全機構です。ガードは、真実の複製にではなく、真実に向けなさい。

v1の物語の前の章については、アップロード経路からバックエンドを取り除く(v1.8.11–v1.8.13)を; そして弧の全体については、ソフトウェアを完璧に出荷することの解剖学をご覧ください。

関連記事

Web Audioを生かし続ける三つの動詞
Steven
Steven2 分で読める

Web Audioを生かし続ける三つの動詞

二ヶ月を隔てて別々のファイルで出た二つのGeekByeポイントリリースが、私たちのオーディオコードに同じ教訓を正反対の端から教えました: ブラウザのAudioContextを使い捨てとして扱うのをやめよ、と。一方のリリースは、macOSが録画中にこっそりsuspendしたコンテキストをresume()することを学びました; もう一方は、連続するセッションがChromiumのおよそ六コンテキストの上限に激突するのをやめさせるべく、close()する代わりにsuspend()することを学びました。resume、suspend、close — それが筋の全部です。

エンジニアリング
Audio
デスクトップ
アップロード経路からバックエンドを取り除く
Steven
Steven2 分で読める

アップロード経路からバックエンドを取り除く

GeekByeはあなたの画面を録画し、その動画をあなたのGoogle Driveへ保存します。最初のバージョンは、そこへ至る途中で、すべての録画をGeekBye自身のサーバーを通して送っていました; ひとつリリースを経て、ファイルはあなたのマシンからDriveへ直行するようになり、バックエンドはただ一つのポインターを保持するだけに格下げされました。面白いのは、その「直接で、再開可能な」バージョンが実際にはどれほど少ないコードしか含んでいないか、です — なぜなら再開可能性は、プロキシを書くことからではなく、それを削除することから来たからです。

エンジニアリング
アーキテクチャ
デスクトップ
十二分で生まれ、そして直された
Steven
Steven2 分で読める

十二分で生まれ、そして直された

GeekBye v1.8.10のchangelogは、キーボードショートカットの編集中のクラッシュを直したと言います。直したのは本当です — けれどそのクラッシュは同じプルリクエストの中で、十二分の間を置いて、導入され、そして直され、ただの一人のユーザーにも届きませんでした。本当の物語は、それを生んだ信頼性の連鎖です: ショートカットをいつ一時停止するかへの、小さく正しい変更と、エディターをより安全にするための一行から転がり落ちたReactのティアダウンのバグ。

エンジニアリング
React
デスクトップ