🤫

Whisper を使ってオンライン会議向けの書き起こしアプリを作った

2024/02/03に公開

https://x.com/zaburo_ch/status/1752832716406727141?s=20

今更 Whisper を使ってみた話を誰が読みたいんじゃという気持ちますが、ものを書く習慣づけのために記憶が新しいうちに吐き出しておきます。

背景

英語が苦手です。仕事柄英語で書かれた論文やドキュメントを読むので Reading は比較的できて、それ以外も少しずつ訓練はしているのですが、まだまだ実用レベルに達していません。リスニングは一朝一夕でできるものではないらしいので、訓練は続けるものの、それまでの間なんとか凌げるように機械に頼りたいです。

オンライン会議では、アプリに字幕機能がついていることがあって (e.g. Google Meet)、これを使えばリスニング部分はかなりましになることに気づきました。一方で、書き起こしのミスを脳内で補完しながら文をパースしているうちに前の文が流れていってしまったり、パースに脳のリソースが使われすぎて少し前に言っていたことを忘れてしまったりということが多々ありました。また、オンラインの学会などでそもそも字幕機能のついていないサービスを使うこともありました。

そういった事情で、"字幕" ではなく文章を全部残しておく "書き起こし" を行うアプリで、オンライン会議の音を拾ってリアルタイムでやってくれるものが欲しいなぁと思いました。

要件

欲しいのは下記の要件を満たすアプリです。

  • macOS で使えること
  • 字幕としての使用に耐えるくらいの速度と精度があること
  • 仕事で使えること (内容を外部に送信したりしないこと)
  • Loopback なしで使えること
    • マイク入力ではなくてオンライン会議の音声などを入力としたい場合、macOS では SoundFlower, BlackHole などで仮想サウンドデバイスを作って Loopback する方法が一般的ですが、各種アプリでデバイスを適切に選択する必要があって面倒になるのでできれば避けたいです

暫く検索してみた感じでは以上の要件を満たす既存のアプリは無さそうで、最悪見つけられていないものが有っても車輪の再発明は楽しいので OK という気持ちでつくることにしました。

技術的なところ

大まかな選定

書き起こしのコアの部分は Whisper が十分に速そうでライセンス的にも寛容 (MIT) なのでとりあえずこれを使ってみることにしました。Whisper がリリースされたのは1年以上前なので、その後により良い手法やモデルが公開されているかもしれませんが、暫く検索してみた限りでは簡単に試せそうで明確に Whisper より良さそうなものは見つからなかったのでこれを使うことにしました。

音声の取得を Loopback なしでやるところですが、Swift からであれば macOS 13.0 から録音もできるようになった ScreenCaptureKit を使えばできそうなことがわかりました。Whisper などの機械学習モデルは Python からだと断然簡単に触れるので、Python からできる方法もないか調べたのですが良い方法は見つかりませんでした[1]

Whisper と ScreenCaptureKit が使えれば良さそうなので、

  • Whisper が簡単に使える Python で、Tkinter などを使って GUI を作りつつ、PyObjc で ScreenCaptureKit を呼び出す
  • ScreenCaptureKit の実装例が比較的充実している Swift で whisper.cpp などを使って Whisper を実行する

の 2 つの選択肢がありえそうでした。最終的に Swift と XCode を使って macOS 向けの GUI 作れるようになっておくと良いことあるかもという軽い気持ちで Swift を選択しましたが、存外に大変だったので後々めっちゃ後悔しました... たぶん Python からやる方法の方が圧倒的に楽に動くものが作れるとおもいます...

Whisper を Swift から使う

「Swift Whisper」 とかでググると SwiftWhisper という whisper.cpp の Swift Wrapper が出てきますが、こちらは最近はメンテされていないようで少し内容が古いです。

最近の Swift は直接 C++ を使うことが可能になったらしいので、最初はそれを使って自分のアプリに whisper.cpp のソースコードを同梱して... ということをやっていたのですが、実は whisper.cpp は3ヶ月前くらいにそのまま Swift の Package として読み込めるようになっていたので、最終的にはこれを使わせてもらいました。この PR で Package.swift が追加されていますね。
https://github.com/ggerganov/whisper.cpp/pull/1370

XCode から 「Add Package Dependencies...」 で whisper.cpp に github URL を指定するだけで導入できます。デフォルトだと 1.5.2 を checkout してくるようになっていたのですが、このままだと build が通らずその時点での master (e72e4158) を指定するようにしたら通りました。

whisper.cpp は example が豊富なのでこれを参考に実装しました。main, stream, whisper.objc あたりが特に参考になりました。

https://github.com/ggerganov/whisper.cpp/tree/master/examples/main
https://github.com/ggerganov/whisper.cpp/tree/master/examples/stream
https://github.com/ggerganov/whisper.cpp/tree/master/examples/whisper.objc

途中、Swift から use_gpu=false で実行すると極端に遅くなるという現象にかなり悩まされました。whisper.cpp の make でコンパイルしたバイナリを実行すると 300ms くらいで終わる処理が、Swift からの実行だと 5s くらいかかっていました。Release モードで build して、コンパイル時のフラグも make で付けられるもの (-DGGML_USE_ACCELERATE -O3 -DNDEBUG あたり) がついていることも確認したんですが、結局解決できませんでした。幸い Metal を有効にして build して use_gpu=true で実行した場合の速度は問題なかったのでこちらを使用することにして実装を進めました。

ScreenCaptureKit を使う

公式のデモがあるのでそれを参考に実装しました。このデモはなんと macOS 13 では動かないのでなんとかエスパーしながら動かします。
https://developer.apple.com/documentation/screencapturekit/capturing_screen_content_in_macos

Whisper でリアルタイム処理をする

書き起こしでは前後の文脈が正しい推定をする上で大事なので、ある程度長い chunk に対して予測をしたいのですが、長い chunk になるまで予測をしないとリアルタイム性が損なわれるという問題があります。chunk が短い間にも予測をして暫定的な予測値を表示しておき、予測が信頼できる長さになったらその予測を確定させて対応する音声データを捨てることで精度とリアルタイム性が両立できます。whisper.cpp の stream example はそういった発想で実装されていて、chunk の長さが一定を超えるまで予測を確定させずに繰り返し、一定の長さを超えたら chunk 全体の予測を確定させるようになっています。しかし、この方法だと chunk の終わりが文の途中になった場合に、文の最後の推定が上手くいかなかったり、推定された文がうまく次の文に繋がらなかったりします。

whisper には長い文章を入力したときに、複数の segments に切って、各 segment の開始時刻と終了時刻を出力してくれる機能があるので、これを使って chunk の切れ目を決めるようにしました。具体的には、whisper に入力して複数の segment が出力された場合、最後の segment 以外を確定してそこまでのデータを捨てるようにしました。

今回、segment レベルの時刻を使って chunk を区切るようにしましたが、Whisper で単語レベルの時刻を出力できるようにしようという試みはいくつかあるので、そういった工夫を導入することで、文の途中で chunk を切って長い分を途中で確定できるようにもできるかもしれません。単語レベルの時刻の推定自体は Demo を見る限りはそこそこ成功してそうにみえます。
https://github.com/jianfch/stable-ts
https://github.com/linto-ai/whisper-timestamped
whisper.cpp にも token.t0, t1 を推定する機能がありそうですが、私が使ってみた限りではうまく推定できていなそうでした。入力がもっと長ければ上手くいくのかもしれません。

無音の領域が含まれていると Whisper の挙動がおかしくなるという問題もありました。この問題については input の先頭に一定の長さ以上の無音区間が含まれる場合はそれを捨てるようにして軽減しました。無音区間の判定は適当な長さの塊に区切って、信号の絶対値の平均が一定以下なら無音区間としました。判定方法はかなり素朴なもので改善の余地はありますが、今回のユースケースだと入力される音声信号は既に他のアプリでフィルターされていることが多いので、この方法でもある程度機能しそうだと判断しました[2]。無音区間の推定は末尾側でも行っていて、末尾に一定以上の無音区間がある場合は、1 segment しか出力されていない場合でも予測を確定させ、新しいパラグラフに移るようにしています。

余談

Swift、初見結構びっくりする見た目をしていませんか...? Swiftツアーだけ読んで特攻したんですが、いきなり

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("Hello, world!")
        }
        .padding()
    }
}

みたいなコードが出てきて、なんでこんな位置に {} が...? C++ の initializer_list みたいなのがあるの...? 聞いてないぞ...? ってなりました。

実際には(自分の理解があっていれば)

という感じのはずです...

どれも言語ガイドには書いてあったのでちゃんと全部読めという話ではありますが、こういうのを AI に教えてもらえると嬉しいですよね。こういう「これは何...?」みたいなものを ChatGPT とか Bard に聞きたいときのうまいやり方が知りたいです。自分が愚直に聞くとそれが聞きたいんじゃないんだよなぁという答えが帰ってきてしまう。

まとめ と Future Work

オンライン会議で使えるようなリアルタイムの書き起こしアプリを作りました。勢いで Swift に挑戦してみましたが、嗚呼、あれもこれも Python なら一瞬でできるのに... と思いながら実装していました。単に Python に慣れているというのもありますが、やっぱり Python の機械学習まわりのエコシステムが強いというのを再認識させられました。Python 以外からもたくさん使われている Whisper でこれなので他のモデルを Swift で動かすとなったらもっと大変なんだろうなと想像しています。

Future work として、UI 周りはいくらでも改善の余地はあるのでちまちま改修していくとして、もっとコアの部分で気になっていることをメモしておきます。

  • 細かい処理で体験が悪くなっている部分を直す
    • 長い文 (長い segment) を書き起こすときに、文の最初のほうが何度も書き換わると読みにくいので、未確定にする Token 数に上限を設けて、文の途中でも確定されるようにする
    • パラグラフの開始直後、入力がある程度長くなるまで whisper がうまく機能せずレイテンシが落ちているように見えるので dummy の入力を入れるなどして直す
  • Whisper より後発のものでもっと良いものがないか、つくれないか検討する
    • Meta の Seamless Communication という技術は凄そうで、W2v-BERT 2.0 speech encoder の部分は MIT で公開されているそうなのでこれを使ってなにかできたりしないか
  • Voice Activity Detection
    • silero-vad という ONNX で動く機械学習ベースの手法がデファクトっぽいので使ってみたい
脚注
  1. SoundCard を使うとできるという情報もあったのですが、自分が試した限りでは macOS ではできませんでした ↩︎

  2. 実際には機械学習ベースのVADのデファクトになってそうな silero-vad を使ってみたかったのですが、ONNXRuntime の build がコケてしまってどうにもできなかったので導入を諦めました... ↩︎

Discussion