録音ファイルからローカルで議事録を作成する仕組みを作った話
きっかけは、iPhone標準のボイスメモの文字起こし機能があまり役に立たなかったこと。固有名詞はもちろん、通常の会話でも誤変換が多く、読める代物ではなかった。
それなりに精度の高い文字起こしが欲しかったのである。
録音方法と送受信のしくみ
録音方法
まず考えたのは録音ツールの改善であった。
専用のAIボイスレコーダー(PLAUD Noteなど)を買うことも考えたが、荷物を増やしたくなかったのと、iPhoneとそこまで録音品質も変わらないらしいとのことで却下。
iPhoneとMacならファイル送信の仕組みも組みやすかったので、結局iPhone標準のボイスメモに落ち着いた。
送受信のしくみ
ファイルの送受信については、最初はSyncthing(ファイル同期ソフト)での同期を考えたが、すでにTailscale(VPN)を導入済みだったので、Taildrop(Tailscale版Airdrop:同一ネットワーク内でファイルを送受信できる)でiPhone⇔Mac間のファイル転送を採用した。
ここまでは順調だった。
文字起こしを動かしてみたら、幻聴だらけだった
MacのGPUを活用したかったので、faster-whisperではなくApple Silicon向けのmlx-whisper(mlx-community/whisper-large-v3-turbo)を採用。
「おやすみなさい」が87回
まず動かしてみて気付いたのは幻聴だった。文字起こしの冒頭に「ご視聴ありがとうございました」が3回並んでいる。無音区間でWhisperがYouTube字幕的な文言を出す、よく知られた現象だ。除去リストを作って潰した。それで終わりのはずだった。
だが83分の会議の文字起こしが10,417文字しかない。毎分125文字。日本語の会話としては明らかに少ない。パラメータを変えて比較した。
| 設定 | 文字数 | 「おやすみなさい」 | 最大連続反復 |
|---|---|---|---|
| 既定値 | 10,417 | 87回 | 18回 |
| 対策後 | 16,984 | 0回 | 2回 |
「おやすみなさい」が87回入っていた。私の除去リストにその文言が無かったので素通りしていた。文字数は6,500文字増えた。発話の4割近くが消えていたことになる。
原因は1つだった
Whisperの実装を読むと、原因は1つだった。Whisperは音声を30秒の窓で処理し、既定では直前の窓の出力を次の窓のプロンプトとして渡す(condition_on_previous_text=True)。無音区間で一度「おやすみなさい」が出ると、それが次の窓の文脈になり、モデルは自分が書いたものを見て自信を持って続きを書く。自己増幅のループに入る。
さらに、無音を捨てる安全装置には「確信度が高ければ無音判定を取り消す」という条件がある。自分の出力をコピーしているだけの幻聴は確信度が非常に高い。幻聴であることが、安全装置をすり抜ける理由になっていた。そして判定に引っかかった窓は、中に実際の発話が含まれていても丸ごと捨てられる。
ゴミには自信を与えて残し、本物の発話からは自信を奪って捨てる。 1つのループが両方を引き起こしていた。
対策
condition_on_previous_text=False # 増幅を止める
no_speech_threshold=0.275 # 無音判定を早める
word_timestamps=True
hallucination_silence_threshold=2.0 # 無音に乗った幻聴を落とす
なお condition_on_previous_text=False を単独で入れると逆効果だった(別の語が74回連続した)。窓をまたぐ増幅は止まるが、1つの窓の内側でループが育つのは止められない。処理時間は伸びたが、内容が正しくなければ議事録の意味がない。
さらに精度を突き合わせられるよう、まずturboで一次実行して素早く結果を確認し、続けてlarge-v3でも回す二段構成にした。
文字起こしが手元に来ると、次は議事録にしたくなった
まともな文字起こしが得られるようになると、「これをそのまま議事録にできないか」と思うのは自然な流れだった。同じくローカルのLLMで文字起こし結果を議事録として整形する処理を追加した。
9Bと14B、どちらを使うか
モデル選定では16GBという制約があり、実測でGPUの上限は12.7GBだった。83分の実会議でmlx-lm経由のQwen3.5-9BとQwen3-14Bを比べた。
| Qwen3.5-9B | Qwen3-14B | |
|---|---|---|
| 出力量 | 4,468字 | 1,659字 |
| 時間 | 2.5分 | 3.5分 |
| GPUピーク | 7.1GB | 10.1GB |
14Bは議題の見出しこそ立てるが、中身が定型文で埋まる。読んでも会議の内容が分からない。9Bは議論の流れを追え、不明瞭な箇所を明示して確認事項に落とす。パラメータ数は少ないが世代が新しい。この用途では明確に上だった。Qwen3.5-9B(4bit)を採用した。
議事録側でも、同じ種類のバグが起きた
動かし始めてすぐ、議事録の出力がおかしいと気付いた。15,055文字の議事録のうち、実質的な中身は817文字。残りの95%が同じ一文の繰り返しだった。自己回帰モデルが自分の出力に引きずられて同じ場所を回る。Whisperで起きたのとまったく同じ構造だった。
対策も似ている。繰り返しペナルティを入れ、温度を0.3から0.6に上げた(低すぎるとループから抜け出せない)。そして後段に保険を置いた。
REPEAT_LOOP = re.compile(r"(.{2,400}?)\1{3,}", re.DOTALL)
列挙に頼らないのが要点だ。「おやすみなさい」を取り逃した反省で、知らない文言でも潰せる形にした。
直したはずが、検証するたびに仮説が外れた
ここまでで動くには動くようになった。念のため精度を検証してみると、想定はだいたい外れた。
外れた仮説たち
「音量を均せば遠い声を拾えるはず」 → 外れ。前処理を入れたら正解率が17.9%から17.1%に下がった。
「録音が小さすぎるのが原因だ」 → 半分外れ。冒頭が欠けた1本を調べて出した結論だったが、その音声は条件の違う録音で、実会議とは前提が異なっていた。
「大きいモデルの方が正確なはず」 → 場合による。2本のサンプルで結論が割れた。
評価指標そのものが間違っていた
自分が作った評価指標が間違っていた、というのが一番痛い。用語の正解率を出したが、頻出語1語を除くと順位が逆転した。
それでも直せない限界があった
業務特有の固有名詞(社内の手続き名や拠点名など)は、どう設定を変えても一度も正しく出なかった。学習データに存在しない語は、音響をどう改善しても出力できない。文字起こし側の改善余地は、この種の語に関しては既に尽きている。
解決策として、用語集をデータファイルとして持ち、議事録生成の前に機械的に置換する形にした。コードではなくデータなので、気付いたときに書き足せる。
結局、一番効いたのは録音そのものだった
設定やモデルより手前にあったもの
パラメータを調整し、モデルを変え、後処理で保険をかけ、それでも精度は思ったほど上がらなかった。何度か検証しているうちに気付いた。設定やモデルをどういじっても、録音そのものの質が悪ければ天井が低いままだということに。
マイクの位置、部屋の反響、話者との距離。ソフトウェア側でどれだけ手を尽くしても、この土台が悪いと精度は頭打ちになる。逆に言えば、録音さえ良ければ、既定値に近い設定でもかなりのところまで届く。
一番時間をかけたのはWhisperのパラメータ調整とモデル選定だったが、精度を一番左右していたのは、その手前の「どう録るか」だった。
まとめ
作るのは半日で終わった。残りの時間はすべて「本当に正しく動いているか」を確かめることに使った。そして確かめるたびに、想定が外れた。行き着いた先は、ソフトウェアの外側にある一番地味な要因だった。
AIを組み込んだ仕組みは、壊れていても動いているように見えるのが厄介だ。文字起こしは出力される。議事録も生成される。パイプラインはエラーを出さない。4割の発話が消えていても、87回の幻聴が混ざっていても、見た目には成功している。
実測しなければ、私は今も「うまく動いている」と思っていたはずだ。
コメント
GitHubアカウントでログインすると投稿できます。