🎵

Jevで口笛を分類してMacを操作する「Whistle」を作りました

に公開

口笛でMacを操作する「Whistle」を作りました。口笛を登録すると、対応するアプリやショートカットを呼び出せます。

口笛の分類には、TypeSafeの推論モデルJevを使っています。Macで音程とリズムを数値に変換し、登録サンプルと一緒にJevへ渡します。マイクから取得した音声そのものはAPIへ送信しません。

口笛をMacで数値化し、Jevの分類結果からアプリやショートカットを実行する全体図

https://github.com/kentaro/whistle

口笛と操作を登録する

呼び出すアプリかショートカットを選び、口笛を3回吹いて登録します。複数のサンプルで、音程やタイミングのばらつきを捉えます。登録したサンプルは一覧から再生できます。

口笛とアプリ・ショートカットの対応を表示したWhistleの画面
アプリの起動・前面表示と、ショートカットの実行を同じ一覧で管理します。

デモでは、口笛でCodex、X、Google Chromeを開き、Xへ投稿するショートカットも実行しています。投稿処理はショートカット側に定義しています。

以下のポストに添付した動画を再生して、デモをご覧ください。

https://x.com/kentaro/status/2102382428392435837

音声から操作までの処理

実装はSwiftで、画面にはSwiftUI、マイク入力にはAVAudioEngineを使いました。

Mac側では、音量・周波数・周期性などの閾値で口笛らしい音を選別し、区間を切り出します。通過した入力がどの登録に対応するかはJevが判定します。登録サンプルとのローカル照合や、Jevの確信度による追加の足切りは行いません。

口笛の音程を検出する

音程検出では、30ミリ秒ごとに受け取る音声の中央12ミリ秒を解析します。対象は500〜4,000 Hzです。波形の自己相関から周期を求め、周波数に変換します。

自己相関だけでは、会話などの周期的な音も検出候補になります。そこで音量と自己相関のピークに加え、推定した周波数の正弦波で説明できるエネルギーの割合を確認します。現実装では、この割合が0.65未満の区間を除外しています。ただし、スピーカーで再生した同じ純音と、人間の口笛を区別できるわけではありません。

解析窓を12ミリ秒にしたのは、短い口笛の中でも音程が動くためです。長い窓の中で周波数が変わると周期性が弱まり、音程の上がる部分を検出できない場合がありました。更新間隔は30ミリ秒のまま、解析する窓だけを短くして音程の変化を残しています。

口笛の開始は60ミリ秒の継続で確認し、音程を検出しない状態が600ミリ秒続いたら、1回分の入力として確定します。受付範囲は150ミリ秒〜6秒です。

実装はSignalProcessing.swiftにあります。

音程とリズムを数値にする

Jevに渡す特徴には、音程の上下と、その変化が起きた時刻を残します。音程だけを固定長の配列にすると、同じ形で長さの違う口笛や、途中に休符を挟む口笛の違いが小さくなってしまうためです。

音の高さは、開始付近120ミリ秒の有効な周波数の中央値を基準にして、相対的な半音数へ変換します。基準の周波数をf_0、各時点の周波数をfとすると、次の式になります。

p = 12\log_2\left(\frac{f}{f_0}\right)

この表現では、同じ旋律を全体に高く吹いても、基準音からの音程差を比較できます。値は0.1半音単位で保持し、小さな音程変化も整数への丸めで消さないようにしました。

特徴データの主な項目は次のとおりです。

項目 内容
timeline 開始からの時刻と相対音程。音程未検出の点はnull
durationMS 口笛全体の長さ
pitch・voiced 32点に要約した音程列と、各点で音程を検出できたか
pitchRange・turningPoints 音域と、要約列で音程の上下が切り替わった回数

timelineの例です。値は説明用です。

[
  { "timeMS": 0, "pitch": 0.0 },
  { "timeMS": 30, "pitch": 0.4 },
  { "timeMS": 60, "pitch": 1.1 },
  { "timeMS": 90, "pitch": null },
  { "timeMS": 120, "pitch": 2.0 }
]

nullは休符の場合も、音程検出に失敗した場合もあります。Jevへの指示にもこの区別の限界を記載し、短い検出欠落を許容しつつ、音の長さや休符の位置を比較させています。32点の要約は補助情報で、時刻付きのtimelineを主な判断材料にしています。

Jevに登録先を選ばせる

JevのSystem One APIには、判定対象のデータをstate、質問をquestionsとして渡します。stateに今回の口笛と登録サンプルを入れ、choice型の質問で登録IDかunknownを選ばせます。モデルの追加学習は行いません。

Jevは同じstateに対する複数の質問を1リクエストで処理できます。現在のWhistleが送る質問はmatchの1つで、旋律・長さ・リズムをまとめて比較させています。登録IDはこの質問の選択肢であり、登録ごとに質問を送っているわけではありません。

リクエストを組み立てるコードの抜粋です。inputとsamplesには、前節の特徴データが入ります。

let state: [String: Any] = [
    "input": try object(input),
    "examples": try candidates.map {
        ["id": $0.id.uuidString, "samples": try object($0.examples)]
            as [String: Any]
    }
]

let question: [String: Any] = [
    "type": "choice",
    "instructions": instructions,
    "criteria": criteria
]

let payload: [String: Any] = [
    "model": configuration.model,
    "state": state,
    "questions": ["match": question]
]

criteriaには登録IDごとの照合条件と、該当する登録がない場合のunknownを定義します。指示文では、旋律・長さ・リズムの3つを比較すること、絶対音高は比較しないこと、長さやテンポの違いを消さないことを指定しました。複数の候補で判断がつかない場合もunknownを選ばせます。

アプリ名やショートカットの内容は、この判定に必要ありません。APIには登録IDと数値特徴を送り、返されたIDをMac側で操作に対応付けます。リクエストの組み立てと応答の検証はJevClient.swiftにまとめました。

接続先はTypeSafeのAPIとロリポップ!AIゲートウェイに対応しています。このゲートウェイ経由では、モデルにtypesafe/jev-latestを指定します。JevにはSystem One APIを使い、Chat Completions形式とは区別しています。

判定結果から操作を実行する

アプリを呼び出す場合は、登録してあるbundle identifierから起動先を解決します。起動済みのアプリは前面に表示します。Chromeで作成したアプリは、macOSのopenコマンド経由で開くようにしました。

ショートカットにはmacOS標準のshortcutsコマンドを使っています。一覧取得時にIDを読み取り、実行直前にも対象が残っていることを確認します。名前を変更しても対応を維持できるよう、登録は名前ではなくIDに紐付けています。

shortcuts list --show-identifiers
shortcuts run <登録済みのショートカットID>

応答の形式と選択肢を検証し、登録済みのIDに対応する操作を呼び出します。不正な応答、通信エラー、unknownでは実行しません。

ただし、形式の正しいIDでも分類結果が誤っている可能性は残ります。操作せず結果を確認できる「認識テストのみ」を用意しました。待受の停止や設定変更時には処理中の認識を無効にし、古いAPI応答が戻ってきても実行しないようにしています。実行後は1秒間、次の操作を受け付けません。

入力モニターで確認する

入力モニターには、検出中の音程、切り出した口笛のグラフ、APIの判定、実行結果を表示します。うまく反応しないときは、音程が取れていないのか、区切りが確定していないのか、Jevがunknownを選んだのかを確認できます。

音程グラフとAPIの判定ログを表示した入力モニター
入力モニター。音程グラフと判定ログ、Slackの実行結果を確認できます。

画面上部の「確信度」はローカルの音程検出の値で、判定ログにあるAPIの確信度とは別です。APIの確信度と候補確率は診断用に表示し、実行可否の独自の閾値には使っていません。

画面の「判定〜完了」は、特徴抽出後からAPI判定・操作の処理までの時間です。口笛を吹いている時間と、区切りを確定する600ミリ秒は含みません。

保存するデータと検証範囲

登録音声は再生用にMac内へ保存します。通常の待受音声は短いメモリバッファに保持し、ファイルには保存しません。設定と数値特徴はローカルのJSONに、APIキーはmacOSキーチェーンに保存します。

自動テストでは、合成音による音程検出、口笛の区切り、移調した入力、ノイズの除外を確認しています。APIの不正応答や、キャンセル時にショートカットを起動しないことも検証対象です。一方、日常の会話や音楽がある環境での誤発火率、利用者ごとの認識率は、まだ系統的に評価していません。今回のデモは手元の環境での動作例です。

現時点ではmacOS 15以降・Apple Silicon向けの初期実装です。Swift 6以降の環境で、リポジトリからビルドして試せます。

git clone https://github.com/kentaro/whistle.git
cd whistle
./scripts/build-app.sh
open build/Whistle.app

生成するアプリはローカル実行用のアドホック署名で、配布用の公証はしていません。初回にマイクを許可し、APIを設定して口笛を登録します。ショートカットを使う場合は、先にショートカット単体で実行し、必要な権限を確認しておきます。

おわりに

Jevに渡すデータを音程とリズムの数値にすることで、口笛をMacの操作に結び付けられました。ショートカットと組み合わせれば、アプリを開く以外の操作も登録できます。

ソースコードはGitHubで公開しています。

GitHubで編集を提案
GMOペパボ株式会社

Discussion