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 で遊べるので、オススメ。