tokuhirom's Blog

rustmigemo と surrogate pair と

rustmigemo という、migemo を rust で実現できる便利な crate がある。 これを利用しようとしたところ、入力文字列として "h" とか "s" を入れたときに、panic することがわかった。 これは、h とか s とかで始まる文字列として、surrogate pair が含まれているからだということが、panic したところに printf debug していたらわかった。

surrogate pair で検索するような文字を、migemo で探したいことはほぼないと思うので、実際にどのような文字が含まれているかを調べてみることにした。

python で以下のようなスクリプトをさらっと書いて、辞書ファイルをスキャンしてみる。

import codecs
import sys

def is_surrogate_pair(char):
    """
    Checks if a character is in the surrogate-pair range.
    """
    return 0x010000 <= ord(char) <= 0x10FFFF

infile = sys.argv[1]
with codecs.open(infile, 'r') as file:
    for line in file:
        has_surrogate_pair = False
        for char in line:
            if is_surrogate_pair(char):
                print("U+%X" % ord(char))
                line = line.replace(char, f'**{char}**')
                has_surrogate_pair = True
        if has_surrogate_pair:
            print(line)

実行したところ、以下のような文字だということがわかった。

U+20B9F
しか    仕懸    仕掛    仕替    併      兪      叱      史家    呵      四角    始華    子夏    子華    市価    市花    師家    志嘉    志賀    歯科    死花    然
        知客    確      確然    私家    糸価    紙価    紙花    緊      而      聢      芝河    詆      詞花    詞華    詩家    詩歌    賜暇    蹙      顰      飾
        **𠮟**

U+20B9F
しっ    七      卓      叱      執      失      嫉      尻      悉      櫛      湿      漆      濕      疾      確      緊      質      **𠮟**

U+20B9F
しつ    七      仕付    仕詰    叱      喞      執      失      嫉      室      志都    悉      桎      湿      漆      濕      為付    為詰    瑟      疾      膝
        蟋      貭      質      躾      隰      隲      **𠮟**

U+27631
ふき    不帰    不羈    不覊    不記    不諱    不軌    付記    吹      富喜    富貴    布岐    腐気    苳      葺      蕗      袘      附記    **𧘱**

人生で一度も使おうと思ったことがない漢字なので、これはまぁ検索出来なくてもいいだろうという気分になった。

サロゲートペアを含む文字を除外する処理を辞書作成スクリプトから除外すればいいな、と思ったが、、 yet-another-migemo-dict ではビルドツールが全部含まれてるわけじゃなくて手である程度ビルドする感じになっていたので、めんどくさいからスクリプトを書いて、再生成可能なようにした。

https://github.com/oguna/yet-another-migemo-dict/pull/3

Codemirror6 で @codemirror/lang-legacy-modes と @codemirror/lang-markdown を組み合わせる

https://discuss.codemirror.net/t/codemirror-lang-markdown-with-codemirror-legacy-modes/7925

Codemirror6 では、一部のメジャーなプログラミング言語はサポートされているが、5時代にはサポートされていたのに6ではサポートされていないプログラミング言語も多い。 そういったプログラミング言語は @codemirror/lang-legacy-modes を使うことで、利用可能だ。 @codemirror/lang-legacy-modes のドキュメントを参照すれば、Codemirror6 でどのように使うかは書いてある。

しかしながら、Codemirror6 の lang-markdown に対してどのように使うかは書いてなかった。

そこで、フォーラムで質問したところ、@marjin 氏から即座にレスがついたので大変ありがたかった。

@codemirror/language-data というパッケージを使うと、必要なプログラミング言語の文法を動的に読んでくれるらしく、lang-legacy-modes の中身もすぐに使えるようだ。

最高!

tauri 2.0.0 beta1 に移行した

tauri 2.0.0 beta1 が出た。今後は大きい breaking changes はなさそうってことなので、移行してみる。 趣味で作ってるメモアプリを tauri 2.0.0 beta1 に移行した。

趣味だから、まぁ beta でもいいかな的なやつです。

npm install @tauri-apps/cli@next
npm run tauri migrate

ってやると、一通り自動マイグレーションされるので、それでだいたいいける。 が、微妙にぶっ壊れるので、新規で skelton 作って、それとの差分を見ながら調整していくみたいな作業はやっぱり必要。

fs まわり

[dependencies]
tauri-plugin-fs = "2.0.0-beta.0"

というふうに rust 側の Cargo.toml に依存追加する。

src-tauri/src/main.rs に以下のように書いて、プラグインを有効化する必要がある。

fn main() {
    tauri::Builder::default()
        .plugin(tauri_plugin_fs::init())
}

JS の API で、renameFile が rename になったり、removeFile が remove になったりと、メソッド名もそこそこ変わっている。 各APIの dir 指定が baseDir という名前に変わってる。rename の基底ディレクトリが newPathBaseDir と oldPathBaseDir に別れたの最高っぽい。

あとは、 credentails 周りが変わってる。 migration ツールが src-tauri/capabilities/migratred.json に権限リストを移行させてくれるが、権限の名前が変わってるので、そのままだと起動しなくなる。

例えば以下のようなエラーになる。

Permission fs:allow-read-file not found, expected one of app:default, app:allow-app-hide, ...

appdata なら appdata に読み書きする権限が、個別で用意されるようになってるんで、ここのへんのフォルダだけ触る場合は以下のように書いたらよろしい。

{
  "fs:allow-appdata-write-recursive",
  "fs:allow-appdata-read-recursive"
}

メニューが便利

Menu が rust ではなく JS 側でできるっぽいがドキュメントがまだなさそう。 情報が全く見当たらない。これはしばらく待ってから対応したいところ。

SQL が使える

sqlite が組み込みになっている。便利。

まとめ

わりと簡単に移行できる。

heapdive 0.1.0を出した

https://github.com/heapdive/heapdive

CI がコケてる事に気づいたので諸々修正した。

MeetNote2 - Zoom を録音して文字起こしして要約する Mac 用のアプリを作ってみた

Zoom 等の会議があったときに、録音して文字起こしして要約するという一連のタスクがあるわけですが、これをMacのアプリとして実装してみました。

↓このへんからダウンロードできます。 https://github.com/tokuhirom/meetnote2/releases

Zoom 会議の場合には、録画機能などもあるわけだがそういうのを使えないケースもあるのかなと思いますので。。

Technical stack

今回も rust + tauri で作りました。この構成が、ネイティブアプリ作るときには制限が少なくて楽な気がします。Swift で作っても良いんだけど、Swift を覚えるのがめんどくさくて rust で済ませてしまっています。apple-sys を使えば mac の api を呼ぶことにほぼ不自由はないので、swift を覚えるモチベーションが薄いですね。

技術的な難しさ

音声の処理

Mac の場合は screencapturekit という API を利用すると、アプリケーションから発せられる音をキャプチャすることが可能です。screencapturekit-rs というライブラリを使えば、これが簡単にできます。 が、、できます、といいたいところだが、出来なかったので、出来るようにした。パッチを送って取り込んでもらったのであった。wave ファイルとして保存できるので良い。

cpal というライブラリを使うとマイクの音を raw 形式で取得できるので、hound というライブラリで wave 形式で保存していく。

アプリケーションから発せられた音声が入っている音声ファイルとマイクから取得した音声ファイルを sox コマンドでミックスし、ffmpeg で mp3 に変換していく。

音声ファイルは圧縮率高めの mp3 として保存しておいて、後から聞き返せるようにしている。

本当は ffmpeg や sox 使ってるところは、rust のライブラリで処理できるといいのだが、適切なライブラリが見つかっていないのであった。

文字列処理

次に、mp3 ファイルを whisper.cpp を使うと文字起こしが出来るので、文字起こしする。一応、OpenAI の transcription api にも対応しているが、M1 Macbook Pro とかだと whisper.cpp でも十分な速度で文字起こしできるので、whisper.cpp を利用するのがオススメ。

文字起こししたファイルは webvtt 形式で出力される。これを OpenAI の API を使って要約するか、TF-IDF を利用した簡易要約を行う。TF-IDF の場合はローカルで処理が完結するので、重要な情報を扱う場合はこちらのほうがオススメ。本当は、もう少し賢い要約アルゴリズムが rust で利用できると嬉しいので、良い方法を知ってる人がいたら、教えて下さい。お願いします。何卒~~

録音の開始終了処理

録音の開始と終了を制御するのは面倒なので、開始と終了は、Zoom 会議の開始終了とか Teams 会議の開始終了とかのタイミングを自動で検知してやってほしい。

最初は Zoom アプリの CPU 使用率が一定以上高い状態が続いていたら録音するというようなロジックでやっていたのだが、これは誤検知が多いので、window のタイトルを使うことにした。

Window のタイトルが screencapturekit で取れるので、それが対象の window title だったら録音開始するというような感じ。もう少しスマートなやり方もありそうだが、思いついてないです。いい方法知ってる人いたら教えてください。

まとめ

そんな感じで、外部コマンドとかにも依存しまくりであんまり他人に使わせる気があんまない構成にはなっているものの、割りと便利だと思う。

enter キーで submit させつつ、IME の確定では submit させない方法

oninput を使うのが良さそう?

<script lang="ts">

    import {emit} from "@tauri-apps/api/event";
    import type {MessageRepository} from "./repository/MessageRepository";
    import type {Message} from "./Message";

    export let replyTo: Message | null;
    export let messageRepository: MessageRepository;

    let body="";
    let inserted = true;

    async function send() {
        if (body.length > 0 && body.match(/\S/)) {
            if (replyTo) {
                await messageRepository.reply(replyTo, body);
            } else {
                await messageRepository.post(body);
            }
            await emit("sent_message");
        }
        body = "";
    }

    function handleKeydown(event: KeyboardEvent) {
        if (event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) {
            return;
        }
        if (inserted) {
            inserted = false; // ignore the insertFromComposition event.
            return;
        }

        if (event.key === 'Enter') {
            event.preventDefault();

            send();
        }
    }

    function handleInput(event: InputEvent) {
        if (event.inputType === "insertFromComposition") {
            inserted = true;
        }
    }
</script>

<div>
    <form on:submit|preventDefault={send}>
        <textarea bind:value={body} cols="40" rows="3" on:keyup={handleKeydown}
                  on:input={handleInput}
        ></textarea>
        <button type="submit">Send</button>
    </form>
</div>

Rust で Mac の API を直接触るアプリを作る場合は apple-sys を使うのが良いかも

https://crates.io/crates/apple-sys

Rust で Mac のアプリを作りたい場合、Mac OS の API を直接触りたくなることが多々ある。 いや、僕が書きたいアプリがそういうアプリばかりだというだけかもしれないというか、完全にそうなんだけど。

たとえば CoreFoundation であるとか ScreenCaptureKit とか CoreGraphics とかそういうのです。ScreenCaptureKit であれば ScreenCaptureKit-rs を使うとかそういう感じでいろんな crate を使うというのが一般的なのだが、最新の API に対応してなかったりとかAPIをラップした結果、逆に使いにくくなってるケースとかもある。

apple-sys は 、apple-bindgen で自動生成されるので最新のAPIがあるし、一通りのフレームワークを統一されたインターフェースで処理できるので結局これが一番便利かなぁ、という気分になる。

オススメです。

KeyScripten.app:マクロの未来を切り開くプログラマブルキーボードシステム

QMK で増井さんの Dynamic Macro を実装してみた のだが、Mac のレイヤーで普通にやりゃいいんじゃないかという話になったので、やってみた。

rust で実装できないかなぁと思って調べてみたところ rdev を使えば良さそうだということがわかった。が、rdev は Mac, Windows, Linux をサポートしていて抽象化レイヤーとなっていて、要件を満たせないということがわかった。

rdev を見ていると、CGEventTap というAPIがあることがわかって、これをベースにやれば良さそうだった。

CGEventTap を使うと、マウスとキーボードのイベントを取れる。CGEventCreateKeyboardEvent, CGEventCreate とかして CGEventPost すればキーボード入力イベントがポストできる。ということがわかり、これを使えば Dynamic Macro を実装できるということがわかった。

JS でコアロジックを実装してみる。

当初、 DynamicMacro部分の実装を rust でゴリゴリ書いていたのだが、ふと、組み込み言語で実装できるようにしたら DynamicMacro 以外の用途にも使えるよなぁと思いついたので実装してみた。

当初は mruby を使おうと思ったのだが、最新の mruby でちゃんと動く mruby の rust binding が、探した感じなさそうだったので諦めた。

boajs という rust の JS 実装があったので、これを採用することに。JS は書けない人がいないという点が優れている。 基本的な機能は実装されてて問題なさそう。

便利なシステムトレイとアクセシビリティ設定

KeyScriptenはシステムトレイで動作し、直感的な設定ウィンドウを通じてスクリプトの追加や設定の変更が簡単に行えます。また、アプリケーションはユーザーのキーボード入力を全て捕捉するため、Macのアクセシビリティ設定で特別な許可を与える必要があります。

↑こんな感じで設定できる。

コードは以下のような感じに書ける。シンプルでしょ。

(function () {
    const id = "com.github.tokuhirom.samples";
    let latest_flags = undefined;

    registerPlugin(
        id,
        "My great script",
        "my great script",
        function (event, config) {
            if (event.type === "flagsChanged") {
                console.log(`[${id}] flagsChanged: ${event.flags}`);
                latest_flags = event.flags;
            } else if (event.type === "keyDown") {
                console.log(`[${id}] keyDown: ${event.keycode}`);
                if (config.hotkey.matches(latest_flags, event.keycode)) {
                    console.log("Handled hotkey");
                    return false;
                }
            }
            return true; /* true means, CodeKeys should send the keycode to the application. */
        },
        [ /* configuration parameters */
            {
                "name": "hotkey",
                "type": "hotkey",
                "default": "S-M-C-t",
                "description": "Key sequence for something.",
            },
            {
                "name": "size",
                    "type": "integer",
                    "description": "Size of something.",
                    "default": "64"
            }
        ]
    )
})();

プラグインと拡張性:無限の可能性

KeyScriptenは、Toshiyuki Masui氏の「ダイナミックマクロ」の実装をバンドルしています。ダイナミックマクロについては、何もしなくてもすぐに使えます。 実装はこのへん https://github.com/tokuhirom/KeyScripten/blob/main/keyscripten-core/js/dynamic-macro.js

さらに、JavaScriptを使用したカスタムスクリプトにより、ユーザーは自分だけの独自の機能やマクロを作成できます。 https://github.com/tokuhirom/KeyScripten/blob/main/HOW_TO_WRITE_SCRIPT.md スクリプトはこのマニュアルの通りに実装すれば動きます。

まとめ

KeyScripten.appは、キーボードマクロシステムの概念を根本から再定義し、プログラミングスキルを活かして日常の作業を効率化したいMacユーザーにとって理想的なツールです。JavaScriptを駆使したこのアプリケーションは、キーボード操作のカスタマイズと自動化を新たなレベルに引き上げています。KeyScriptenの開発過程は、技術的な困難と創造性の融合を示し、RustとJavaScriptの可能性を探求する興味深い旅でした。

このアプリケーションの将来は、コミュニティの参加とフィードバックによって形作られます。KeyScripten.appは、キーボードマクロとプログラミングの世界を一つにする革新的な一歩です。

See also

Keyballを中心に自作キーボードを3個作った話

ポインティングデバイスとキーボードは一体化しているべき派なので、Keyball にずっと興味があった。 これまで2年ぐらいUltimateHackingKeyboardを使ってきたのだが、いい加減飽きてきたというところもある。飽きてくるとかそういう問題か?とも思うが。。ポインティングデバイスとしてポインティングスティック(Thinkpadの赤ポチのこと)を使っていたのだが、ときどきドリフトするからソフトウェアリセットが必要だったりする。また、あんまり使いやすくはなかった。トラックボールも小さくて、あんまり使いやすくはない。わりとケンジントンのトラックボールと併用しながら使わざるをえない感じなのであった。

というわけで、自作キーボードに手を出すことにした。

Ergo68

自宅にははんだ付けをする設備がないので、工具を買うところから。 工具セットの全部入りを買った。結論からいうと、ハンダゴテとピンセットと半田とハンダ吸い取り線はいいけど、それ以外はいらないかも。。逆作用ピンセット、フラックスとかは正直なくても大丈夫だった。あと、コテ台は使いやすいのだが、、使わないときに邪魔でしょうがない。上と下が分離できるタイプのほうが良かったかも。が、色々選んで買うとそれだけで疲れちゃうので、一括で買ったこと自体は後悔してないです。 https://shop.yushakobo.jp/products/a9900to

まず最初に、ほとんどはんだ付けがいらない Ergo68 を買ってみた。これはほとんどはんだ付けがいらないのだが、Pro Micro のはんだ付けに失敗して、一回休み、となった。

https://github.com/yushakobo/build-documents/tree/master/Ergo68 これを参考にやったのだが、どういう状況でやるのかよくわかってなくて、コンスルーだけProMicroにつけて机においてはんだ付けしたら死んだ。しかも、ProMicroに同梱されてる足をつけたりとかしてて、もうだめだった。 https://shop.yushakobo.jp/products/ergo68?_pos=1&_sid=01cd99f3d&_ss=r まぁ、そんなに高い部品じゃないのでもう一個買いました。

Keyball61

というわけで、本命のKeyball61を買った。Ergo68に比べると難易度は圧倒的に高い。

ダイオードが小さくてちょっと難しいけど、まぁどっち向きか見えないというほどではない。光の加減で見える。当方、視力は0.7ぐらい。ピンセットがないと作業無理なので、ピンセットは必須。

どのぐらいはんだ付けしたらいいのかがよく分からなくて、何個かはんだ付け失敗してて、難しかった。 github discussions で、どこを見直したらいいですか~?って質問させてもらって、なんかつけ直したりしてたら動くようになった。

組み立てて、何日か使ってみると、Keyballのボールはなかなかに大きくて、これなら他のポインティングデバイスはいらないなって感じ。 ボールを右につけたのだが、ボールを左手につけたほうが、右手を自由に使えるので便利棚、と思って、もう一台ほしくなった。

61を使ってみた結果、44までキーを減らしてもいけそうだなって気分になっていたので、44の乗り換えキットを買うことにした。

Keyball44

Keyball61からの乗り換え割がオトクだったので購入。数字キーがないので、複数キーを押さないと行けないのがちょっと大変だったけど、慣れた。

基本的にはKeyball61とほぼ一緒なので、ほぼノーミスで完成。

オートマウスレイヤーがほしくなったので、ファームウェアも少しいじることに。昔はオートマウスレイヤーの実装は自前でゴリゴリ書かざるを得なかったが、QMKが公式で対応していて、オートマウスレイヤーへの切り替え、実はそんなに難しくなさそうなのでやってみたらできた。マウスクリックするときに、一個ボタン押すだけで良くなったので、めっちゃ快適。オートマウスレイヤー、ちゃんと切り替えられるようにちゃんと作り込んでPull-request を出してみた。 https://github.com/Yowkees/keyball/pull/454

テンティング

https://amzn.to/3HebFGz これを2個使ってる。安定してていい感じ。

BLE 化

BLEMicro使って無線化するのもいいかもしれない、、と思ったが、正直どこかに持って歩いたりする予定はないので、机の下を配線する方向で検討中。

まとめ

Keyball44はかなりよくできていて、オススメ。

boa が面白い

https://github.com/boa-dev/boa

boa は pure rust で書かれた JS Interperter である。組み込みがとにかく容易で依存もないので、プログラミング言語を rust に組み込む場合には、第一の選択肢になりうるのではないだろうか。

tauri で system tray アプリなどで、ウィンドウを閉じたら終了されてしまうとき

https://tauri.app/v1/guides/features/system-tray/

tauri で system tray app を作っていて、設定画面を作った。設定画面を閉じたらアプリが終了する怪現象が発生した。

SystemTray周りのドキュメントを読んだら、普通に解決策が書いてあった。

tauri::Builder::default()
  .build(tauri::generate_context!())
  .expect("error while building tauri application")
  .run(|_app_handle, event| match event {
    tauri::RunEvent::ExitRequested { api, .. } => {
      api.prevent_exit();
    }
    _ => {}
  });

QMK firmware の開発メモ

QMKファームウェアを開発するには、どうするべきか

brew install qmk/qmk/qmk とすると、qmk がインストールできる。これは、めちゃくちゃ時間かかる 。数時間かかるので注意。

qmk のビルドは、ln してやる。make SKIP_GIT=true keyball/keyball44:tokuhirom とかする。

qmk flash ... とすると、書き込むことができる。キーボードのリセットボタンを押すと自動的に書き込みが開始される。最初は pro micro web writer で書いていたが、自分でファームウェア書くならCLIで書いた方がいい。

debug の方法

qmk console とすると、コンソールをモニタリングできる。

rules.mk に CONSOLE_ENABLE = yes に書く。

void keyboard_post_init_user(void) {
  // Customise these values to desired behaviour
  debug_enable=true;
  debug_matrix=true;
  //debug_keyboard=true;
  //debug_mouse=true;
}

のように書いて、デバッグ機能を有効化する。

bool process_record_user(uint16_t keycode, keyrecord_t *record) {
  // コンソールが有効化されている場合、マトリックス上の位置とキー押下状態を出力します
#ifdef CONSOLE_ENABLE
    uprintf("KL: kc: %u, col: %u, row: %u, pressed: %u\n", keycode, record->event.key.col, record->event.key.row, record->event.pressed);
#endif 
  return true;
}

のようにして、printf debug できる。

https://github.com/samhocevar-forks/qmk-firmware/blob/master/docs/ja/newbs_testing_debugging.md

QMK で増井さんの Dynamic Macro を実装してみた

https://github.com/tokuhirom/qmk-onemoretime

Keyboard のファームウェアとして、QMK が有名なわけだけど、その上で増井さんの Dynamic Macro を実装してみた。1ファイルのCライブラリとして実装してるので、自分のファームウェアに簡単に組み込めると思う。

DynamicMacro というのは、

Dynamic Macroの原理は非常に単純で、

    「同じ編集操作を2回繰り返したあとで[CTRL]+[t]を押すと繰り返された操作が再実行される」

というものです。「二度あることは三度ある」と言うように、同じことが二度あればもう一度あるのは世の中でごく普通のことです。二度実行した操作をもう一度実行することもよくあることですので、この方法はたいへん効果的です。

というようなやつ。

なんで作ったの?

なんかできそうなのでやってみただけです。 動いてみるとなんか楽しいです。

どう作ったの?

キーが押されたときに、リングバッファに押したキーを保存しておく。

任意のショートカットキーが押されたタイミングで、繰り返しコマンドになっている区間を検出して、処理する。

難しかったことは?

モディファイヤーキーの処理が難しい。キー送出している間はモディファイヤーキーを押してることにしちゃいけないので、状態を変えてもどしたりした。

改善点は?

メモリの要素をナイーブに舐めてるけど、ちゃんと memcmp とかにしたほうがエコかもしれない。が、ちょっとめんどくさいのでナイーブに実装した。

なんでこんな名前なの?

DynamicMacroという名前は QMK そのもので使われているから。Staticにハードコードされてない、キーボード側でマクロ記録開始!とかマクロ再生!!とかできるようなやつが DynamicMacro なのである。

もう一回実行するというところが本質かなと思うのでこういうネーミングとしてみた。

まとめ

気軽に組み込めると思うので、お気軽に試してみてね。

screencapturekit-rs がpanic!するのを直した

https://github.com/svtlabs/screencapturekit-rs/pull/14

何故か panic! していたところがあったので、ちゃんと Result を返すように修正した。

screencapturekit-rs に音声キャプチャ機能をつけた話

https://github.com/svtlabs/screencapturekit-rs/pull/13 pull-request を出しただけで、まだマージされてないんだけど。

最近のMacでは、ScreenCaptureKit というのがあって、任意のウィンドウの画面をキャプチャしたり、音声をキャプチャしたりできる。昔は Mac では BlackHole とか SoundFlower とか Loopback とかを入れないと、アプリの音声をキャプチャすることはできなかったのだが、これを使うと、簡単にキャプチャできるし、リアルタイムでの音声ミックスを BlackHole とかでやってると coreaudiod とかでCPU食いまくるってのもあるんで、そういうのを避けたいというのもある。

実際、ScreenCaptureKit 使うと CPU 負荷めっちゃ低くて良い。OBS でも ScreenCaptureKit を処理することで CPU 負荷を減らしたという話がある。 https://applech2.com/archives/20220613-obs-studio-for-mac-next-level-with-macos-13-ventura-screencapturekit.html

ScreenCaptureKit は mac の機能なので、これを使ったアプリを作るなら、 Swift を使うのが王道だとは思いつつ、Swift に慣れてないのと、XCode に慣れてないので、可能なら Rust とかでやりたいなと思っていると、そのものずばりの crate が存在している。 https://crates.io/crates/screencapturekit

よしこれで、音声ファイルをキャプチャするぞ、と思ったのだが、音声を取得する機能がそもそも実装されていないことに気づいた。

(この時点で、xcode で書く方向性も今一度考えたが、ちょっと試してみたらうまく動かなくて諦めたのであった。。結局、XCode で Swift で書いてても、権限周りとかではまるのと、XCode に慣れてなさすぎることと、そもそも rust だろうが swift だろうが、ScreenCaptureKit は新参なので情報が薄いというのもあって、RustRover で Rust で頑張ったほうが楽という結論に。。)

ScreenCaptureKit で、CMSamleBuffer に音声データが取れるので、これをそのまま raw PCM として保存したら、wave に変換して音声ファイルになるやろ、っておもってやってみたら全然そうならなかった。なんかノイズがすごい、ちょっと高い音のデータができた。このバイナリデータは、AudioBufferList という構造体になっていて、サイズ情報とかが混じってるので、ちゃんと AudioBufferList としてデシリアライズしてやらないと使えない。 AudioBufferList には、チャネルごとにバイナリデータが入ってるので、これをうまいこと取り出せば使える。 https://developer.apple.com/documentation/coremedia/cmsamplebuffer-u71

            let mut buffer_size = 0;
            CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer(
                self,
                &mut buffer_size,
                null_mut(),
                0,
                null_mut(),
                null_mut(),
                0,
                &mut null_mut(),
            );

とかして、必要なバッファサイズを計測する。

            let mut block_buffer_ref = CMSampleBufferGetDataBuffer(self);
            let layout = alloc::Layout::from_size_align(buffer_size, 16).unwrap();
            let audio_buffer_list_ptr = alloc::alloc(layout);

            let result = CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer(
                self,
                null_mut(),
                audio_buffer_list_ptr as _,
                buffer_size,
                null_mut(),
                null_mut(),
                kCMSampleBufferFlag_AudioBufferList_Assure16ByteAlignment,
                &mut block_buffer_ref,
            );
            CFRelease(block_buffer_ref as _);
            if result != 0 {
                panic!()
            }

            let audio_buffer_list_ptr = audio_buffer_list_ptr as *mut AudioBufferList;

あとは、こんなふうにして、CMSmpleBufferGetDataBuffer で、データバッファを取り出して、maloc した領域にデータを取り出す。

        let audio_buffer_list = unsafe { *audio_buffer_list_ptr };
        let number_buffers = audio_buffer_list.number_buffers;
        let mut buffers = Vec::new();
        for i in 0..audio_buffer_list.number_buffers {
            let audio_buffer = audio_buffer_list.buffers[i as usize];
            buffers.push(CopiedAudioBuffer {
                number_channels: number_buffers,
                data: unsafe {
                    std::slice::from_raw_parts(audio_buffer.data, audio_buffer.data_bytes_size as usize)
                }.to_vec(),
            });
        }
        buffers

データをコピーするとちょっとオーバーヘッドあるけど、、そんなにシビアな用途でもないので、コピーしちゃう。

あとは、この PCM データをチャネルごとに、データを普通にファイルに書いていけばいい。 複数チャネルを一気にファイルに書いていくのは、今回の僕の用途では必要なかったので、、、raw PCM をゴリゴリ書いていき、sox コマンドで、wave ヘッダをつける。 このへん、rust のライブラリもありそうだが、結局 sox コマンド使ったほうが情報多くて楽そうだった。メモリたくさん使う、オーディオ関連の処理は、別プロセスでやったほうが、メモリリークの心配とかがなくて楽というのもある。

そんなこんなで、raw pcm の取得部分まで実装し、サンプルコードを書いて pull-request を出した。 ScreenCaptureKit には夢が詰まってる気がしていて、ScreenCaptureKit で適当に音声をキャプチャした上で、whisper.cpp 使って音声認識するとかすると結構遊べるなと思っている。 初心者にも書きやすい rust でかければ、誰でも簡単に ScreenCaptureKit で遊べるので、オススメ。

Java 21 からは `new URL(String)` は deprecated

https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/net/URL.html#%3Cinit%3E(java.lang.String)

今後は URI.toURL を利用せよとのこと。

Kotlin で GUI アプリを作るならやっぱり jetpack compose for desktop

Jetpack Compose for desktop によるアプリケーション開発

Kotlin で GUI アプリを開発する場合、Swing, awt と JavaFX と Jetpack compose とかぐらい。 今更 awt, Swing などを直接使うのはちょっと大変なので、まぁ kotlin ネイティブな Jetpack compose かなぁ、という感じ。Jetpack compose for desktop でそこそこの規模のアプリを作ったって報告はあまり見かけず、ちょっと不安な感じではありつつ。

現状としては、Jetpack compose 自体は Android で広く使われていることもあって、安定して動くし、情報も多い(ただし Android 用の情報)。 desktop として使う場合には、widget がちょっと物足りなかったりする。商用製品作るぞ!みたいな気持ちだと、ちょっと頼りないかも。エンジニアが自分用ツールを作って楽しむ分には十分すぎるぐらいの機能がすでにある。

JVM で動くので、Java のライブラリがそのまま動く点は魅力。 一方で、JVM で動く故の懸念もある。メモリの使用量。ちょっとメモリ使用量は native でちゃんと丁寧に書かれたアプリより多めかも。

kotlin native 対応が jetpack compose for desktop に入ってくれる未来が来ればそのへんよくなるかもなぁ、と個人的には思っていて、海外でもそういう意見がチラホラ。しかし、現状の jetpack compose for desktop は swing ベースらしいので、その道程は果てしなく遠いのかも。

まぁ、でも普段サーバーサイド用に書いてるプログラミング言語で GUI アプリが割と普通に作れるのって便利だもんで、jetpack compose for desktop もっと流行ったら嬉しいなぁ。

で、具体何つくってるかというと、なんか ChatGPT のモデルとか切り替えながらサクサク使えるやつを作ってて、普通に便利に毎日使っていたりする。

https://github.com/tokuhirom/reflect-ai

Kotlin で Mac の window list を得る

方法を探したが、結局 ProcessBuilder プロセスで AppleScript を実行するのが正攻法っぽい。

import java.io.BufferedReader
import java.io.InputStreamReader

class WindowNameCollector {
    data class WindowState(val processName: String, val processId: String, val bundleId: String, val windowName: String)

    fun getWindowStateList(): List<WindowState> {
        val windowListString = getWindowListString()

        return parseWindowState(windowListString)
    }

    private fun parseWindowState(input: String): List<WindowState> {
        // Regex pattern for matching the input string.
        val pattern = """Process: '(.+?)', PID: '(.+?)', Bundle ID: '(.+?)', Window: '(.+?)'""".toRegex(RegexOption.DOT_MATCHES_ALL)

        // Try to find a match in the input string.
        val matchResults = pattern.findAll(input)
        return matchResults.map { matchResult ->
            val (processName, processId, bundleId, windowName) = matchResult.destructured
            WindowState(processName, processId, bundleId, windowName)
        }.toList()
    }

    private fun getWindowListString(): String {
        val script = """
tell application "System Events"
    set procs to processes
    set results to ""
    repeat with proc in procs
        if exists (window 1 of proc) then
            repeat with w in windows of proc
                set results to results & "Process: '" & name of proc & "', PID: '" & unix id of proc & "', Bundle ID: '" & bundle identifier of proc & "', Window: '" & name of w & "'\n"
            end repeat
        end if
    end repeat
    return results
end tell
        """.trimIndent()

        val pb = ProcessBuilder("osascript", "-e", script)
        val p = pb.start()

        val lines = BufferedReader(InputStreamReader(p.inputStream)).use { reader ->
            reader.readText()
        }

        val exitStatus = p.waitFor() // Wait for the process to finish.
        if (exitStatus != 0) {
            throw RuntimeException("Failed to execute osascript. Exit status: $exitStatus")
        }

        return lines
    }
}

Kotlin multiplatform で生成するコードでは、application block にすべてのコードが入っているので注意。

Jetpack compose for desktop でアプリケーションを書く場合、 https://kmp.jetbrains.com/ で最初のコードを生成すると思うのだが、こいつが生成するコードには少し癖がある。

fun main() = application {
        Window(onCloseRequest = ::exitApplication, state = rememberWindowState()) {
            mainApp.App()
        }
}

のようになっている。

application block は、Compose 側の都合で何回か呼ばれるので、普通の Kotlin app の感覚で、適当に中で Thread { }.start() とか雑に発行してると、スレッドが何個も作られて死ぬので注意。

Jetpack Compose for Desktop で Caused by: java.lang.ClassNotFoundException: javax.naming.NamingException って言われるとき

アプリが完成したなぁ、とおもっていざ ./gradlew packageDmg したとき。

Caused by: java.lang.ClassNotFoundException: javax.naming.NamingException

というエラーがでて起動しなかったりする。

これは、javax.naming などのモジュールが参照できてないので、参照先を追加していくしかない。

compose.desktop {
    application {
        nativeDistributions {
            modules("java.naming")
            // alternatively: includeAllModules = true
        }
    }
}

のように追加していけば OK