チームに日本語が不得意なメンバーがいて、会議なんかの理解に問題があったり、時間がかかったりすることが多いので、何か既存のサービスを使ったらいいんじゃないかなという話が議題に上がりました。
会議ツールが Zoom だったり Google Meet だったり Gather だったり Teams だったりと、その日の都合でバラバラなので、特定の会議ツールに依存しない仕組み が欲しい。どんなサービスがあるのかなぁという時に、最近はGeminiに聞くことが多いのですが、「Linuxなら自分で作るのもアリですよ!なんならオススメ!」(無責任発言)などと気軽に言ってきたので作ってみました(当該社員はUbuntuを使っている)。という話です。
なお、今回もほとんどコードは見てないです。また、このブログも50%くらいはClaude Codeが書いています。ちなみに、~/.claude/以下に保存されている会話履歴からエピソードを引っ張ってきてもらうのですが、結構便利です。ここに書かれていることで、僕自身覚えてないこともありますw
作ったものは edocode/shadow-clerk で公開しています。最近、PyPIにも登録しました。Linux(PipeWire/PulseAudio環境)前提でしたが、Windowsでも動くようにしました。興味があれば覗いてみてください。Windowsは実施済みですが、クロスプラットフォーム対応については 考察メモ に置いてあります(Windows WASAPI ループバック、macOS は BlackHole 等の仮想オーディオドライバ必須、PTTは pynput に寄せる、等)。patches welcome です。
機能一覧(なにができるの)
ざっとできることを並べるとこんな感じです:
- 会議ツール非依存の録音(Zoom / Meet / Gather / Teams など、何でもOK。システム音声+マイクを拾う)
- リアルタイム文字起こし(faster-whisper / reazonspeech k2 / kotoba-whisper から選択。言語が ja の時だけ別モデルを当てることも可能)
- 中間文字起こし(確定前のプレビュー表示)
-
リアルタイム翻訳(LibreTranslate / OpenAI互換API / Claude Code(
claude -p) から選択) - 議事録(要約)生成(会議終了時の自動生成 or ダッシュボードからオンデマンド)
- ブラウザ・ダッシュボード(話者色分け、過去会議の遡り、検索、文字起こし一覧/transcript/翻訳/要約の4ペイン)
- 会議の自動分割(無音検出ベースの二段階判定 + 終了時の空ファイル自動削除)
- Google Calendar 連携(予定開始で自動 start_meeting、ファイル名も予定名で付く)
- 音声コマンド + Push-to-Talk(「会議開始」「翻訳開始」など)
- 用語集(読みの機械置換 + LLM翻訳/要約へのヒント渡し)
- i18n (ja / en): ダッシュボードUI、ターミナル出力、LLMプロンプトが切替可能
- 完全ローカル動作可能(LibreTranslate のみだと要約不可、自前LLMサーバを用意すれば要約までローカル完結)
スクリーンショット多めの紹介は docs/feature-tour.md にあります(スクショは少し古いUIですが雰囲気は掴めます)。
作り始め
新しくアプリをAIで作るときには、簡単な仕様をmdファイルに書いてから、Claude Codeとブラッシュアップして方針を決めて、それをもとにコードを書かせています。
今回は、もともとGeminiにサービスの相談をしていた関係上、そのまま最初の仕様をGeminiに作ってもらいました。
# 目的
Ubuntu環境で、Web会議の音声を録音・ローカルで文字起こししてテキストファイルに継続的に書き出すシステムと、
それをClaude CodeからSkill(またはカスタムコマンド)として呼び出して翻訳・要約する連携システムを作成してください。
# 全体アーキテクチャ
【モジュールA:ローカル録音・文字起こしデーモン】
- Ubuntuのシステム音声(相手)とマイク(自分)をミックスして録音。
- 発話の区切りごとに細かくチャンク化(またはストリーミング処理)し、ローカルの `faster-whisper` で文字起こしを実行。
- 結果を `transcript.txt` にタイムスタンプ付きでリアルタイムに追記(Append)し続ける。
【モジュールB:Claude Code連携(翻訳・要約Skill)】
- Claude Codeが `transcript.txt` を読み込み、要約・翻訳を行って `summary.md` に出力・更新するための仕組み(MCPサーバー、またはClaude Codeから呼び出せるラッパースクリプト)を構築する。
# Claude Codeへの実装指示
以下のステップで開発と環境構築のサポートをお願いします。
## 1. モジュールA(録音と文字起こし)の作成
- `pyaudio` や `sounddevice` などを使い、PulseAudio/PipeWire環境でマイクとシステム音声(monitorデバイス)を同時キャプチャするPythonスクリプト `recorder.py` を作成してください。
- リアルタイム性を高めるため、VAD(Voice Activity Detection)等を用いて無音区間で音声を区切り、`faster-whisper` (モデル: small) に渡してテキスト化するロジックを実装してください。
- 認識したテキストは順次 `transcript.txt` に書き出してください。
## 2. モジュールB(Claude Code連携)の作成
- Claude Codeから手軽に「現在の `transcript.txt` を読んで、日本語の議事録として `summary.md` に出力して」と指示できるような最適なアプローチ(特定のSkillの実装、またはプロンプトテンプレートの作成)を提案・実装してください。
- 必要であれば、差分だけを読み込んで `summary.md` を逐次更新していくようなスクリプトを書いて、Claude Codeにそれを実行させる形でも構いません。
## 3. 必要なパッケージのインストール
- `ffmpeg`, `faster-whisper`, `webrtcvad` (必要な場合) などのインストールコマンドを提示・実行してください。
- README.md にSetupの手順として記載する。
これを、SPEC.mdとして保存して、Claude Codeに作ってもらうことにしました。
※ここで、Claude Code Skillと書いているところがありますが、現在は、claude -p で実装されています。当初は書いている通り、Claude Code Skillで実装していましたが、これは、僕がclaude -p が課金されると勘違いしていたせいです。CIでのAI Code Reviewでは、claude -p を使って、まぁまぁ課金されていたので、その記憶のせいです。OAuth認証している場合は、claude -p でも追加課金はないことがわかったので、最初のSkillでの実装は完全にやめて、claude -p を使うように変更済みです。
とりあえず動くまではすぐ
とりあえずということでは本当にすぐできます。2/27の夕方がファーストコミット(README)で、2/28に多くのコミットをして、3/1にはダッシュボードに取り掛かっているので、最初の構想の話でいうと1日ちょいくらいで終わったんでしょうね(ダッシュボードは元々作るつもりがなかったので)。
とはいえ、まともに動くレベルに持っていくためには割と時間を食いました。
現在のアーキテクチャ
ざっくり書くとこんな感じです。
要するに、
- マイクとシステム音声を常時録音 → Whisperやreazonspeechで文字起こしして transcript に追記
- 翻訳と要約は別系統で、ローカルの LibreTranslate でも、OpenAI互換APIの LLM でも、Claude Code の
claude -p経由でもいける - ブラウザのダッシュボードから transcript・翻訳はリアルタイム、要約はオンデマンドで見ることができて、コマンドも送れる
- 「会議開始」みたいな音声コマンドや、無音の長さからの自動会議切り出しでファイルが分割される
という感じです。詳しい図は SPEC.md にスレッド構成からシーケンス図まで一通り書いてあります。
僕の環境(参考)
これ以降の話で「重い」「軽い」「リアルタイムとは言えない」といった主観的な評価が出てきますが、基準となる僕の環境を書いておきます:
- CPU: AMD Ryzen AI 7 PRO 350 (8コア / 16スレッド, max ~5.1GHz)
- RAM: 64GB(うちiGPUのVRAM予約等で OS から見えるのは ~53GB)
- GPU: Radeon 860M(iGPU、CUDA使えないのでASRは全部CPU前提)
というわけで、ノートPCとしてはかなり余裕がある側の環境です。それでも Whisper medium モデルはリアルタイム処理には厳しい、というのが実感でした。非力なマシンだとさらに苦しい想定で読んでください。
日本語認識の問題
当初、OpenAIのWhisperを使っていたのですが、日本語の認識精度があまり良くない。CUDAが使えればまた違うのでしょうが、今回はCPUでも、快適に動く必要があります。
Whisperにはモデルがいくつかあります。tinyからラージまであります。また、beam_sizeというパラメータもあります。どちらも大きければ精度は上がりますが、遅くもなります。
僕の環境ではmediumモデルだと、お世辞にもリアルタイムとは言えません。というか、数分後にトランスクリプトされるような感じで使えません。
kotoba-whisper
日本語特化のWhisperなんですが、これも結構重く、僕の環境では難しかったです。
reazonspeech k2
こちらがかなり良かったです。余計な音も拾ってしまうのですが、日本語の精度と軽さに関してはダントツでした。
余計な音に関しては、機械的にはじけるものが多かったので、そこまで気にならないくらいにはできました。
モデルを選べるようにした(+ ja だけ別モデルも)
とはいえ、ベストなモデルは環境や用途によって変わるので、どのモデルを使うかは設定で選べる ようにしてあります(ダッシュボードのセレクトボックスから切替可能)。
さらに、「ベースは英語も拾える Whisper small、でも日本語を話してる時だけ reazonspeech k2 に切り替えたい」みたいなニーズに応えるため、検出言語の設定が ja の時だけ別モデルを適用する という設定(japanese_asr_model)も追加しました。中間認識(ダッシュボードでリアルタイムに表示される確定前のテキスト)用にも同じ仕組み(interim_japanese_asr_model)があって、本番とプレビューでモデルを分けたりもできます。
幻覚(ハルシネーション)との戦い
Whisper系のモデルは、無音や雑音から平気で幻覚をひねり出します。「ご視聴ありがとうございました」「字幕は◯◯さん」みたいな定番フレーズはもちろん、なぜか「pgiep」みたいな謎のアルファベット列を吐いてくることもあって、フィルタの正規表現を地道に育てていく作業になりました。
中でも意味不明だったのが「pgiep」事件。transcriptに「pgiep」というアルファベット列が頻出するようになって、フィルタの正規表現に pgiep を追加。ところがその1分後、Claudeが気を利かせたつもりで「これはtypoだから直しますね」と pgiep を pqrep に書き換えてきました(ちなみに pqrep も pgrep でもない謎の文字列)。つまり 幻覚を後から「これはtypoでしょ」と自分で勝手に修正してフィルタを壊す という自爆事件。
さらに不可解なのが、結果として分かった真の原因。デーモンの稼働確認で _is_recorder_running() が PIDファイルを見つけられないと pgrep にフォールバックする実装になっていて、poll-command ループから毎秒 pgrep サブプロセスが spawn されまくっていた のでした。これを止めたら「pgiep」も出なくなり、フィルタへの追加そのものを revert。なぜ pgrep の spawn flood で Whisper が「pgiep」と言い出すのかは今もよく分かっていません(CPU負荷? タイミング?)。
用語集も同じような問題があって、Whisperのpromptに渡すと、今度はその用語集が無音区間からひょっこり出てくる。なので、用語集はWhisperには見せず、後段の翻訳・要約のヒントとしてだけ使うようにしています。
ウェイクワードに敗北 → PTTキーの苦労(今もいまいち)
せっかく常時マイクを聞いているので、Amazon Echo や Google Home みたいに「話しかけたら反応する」仕組みを載せたいよねと思い、音声コマンドも作りました。「クラーク、会議開始」「クラーク、翻訳開始」「クラーク、言語 英語」みたいに、ウェイクワード + コマンドを拾って対応する処理を発火、という定番のパターン。マイクで常時拾っている音声を正規表現で引っ掛けるだけ、理屈としては簡単なはず、でした。
音声コマンドは最初、「クラーク、会議開始」みたいなウェイクワード方式で実装していました。ところがWhisperが「クラーク」を安定して認識してくれない。「ブラーク」「プラーク」「グラーク」「クラーゴ」「フランク」「プラグ」と誤認識のバリエーションが爆発して、マッチさせる正規表現が [ブプグクフ][ラー]{1,3}[ーッ]?[クゴグ]|フランク みたいな魔界になっていきました。
「クラーク」がダメなら別の言葉ならどうかと、デフォルトを「シェルク」という造語に変更。これも「シエルク」「シュルク」+ それぞれの濁音バリアント(シェルグ、シエルグ、シュルグ…)と派生が止まらない。最終的には任意のカタカナ語から濁点・半濁点・小書き文字・ひらがなのバリエーションを全部生成する _generate_katakana_pattern() という関数まで実装しました。しかし、これでも根本解決にはならず。
さらに痛かったのが、メインの音声認識を reazonspeech k2 にしてから判明した事実 — 日本語特化モデルは辞書にない造語(「シェルク」みたいなの)を そもそも音として認識せず無視するか、別の単語に置き換えてしまう。テキストマッチで判定する方式である以上、認識前に消えるものは検出しようがない。ということで、この方向は早々に諦めて、Push-to-Talk(PTT) — キーを押している間の発話をコマンドとして扱う方式 — に逃げることにしました。
ところがPTTもPTTで、地味にハマりどころが多くて:
- どのキーをデフォルトにするか問題: Ctrl/Alt/Shiftはアプリのショートカットと衝突する。Menuキーは無いキーボードもある。F23は安全だけど物理キーが存在しないのでxremap等での割り当てが前提。決定打がなく、結局F23 + xremap推奨という「動くけど面倒」な妥協に
-
WaylandでPynputが動かない: X11では pynput で済むが、Wayland環境では動かない。仕方なく evdev での代替実装を追加。
/dev/input/*の権限でsudo usermod -aG input $USERをユーザーにお願いすることに -
起動した瞬間、勝手にコマンドモード: evdevが起動時にキー状態を読むと、なぜか押されているように報告される現象がたまに起きる。デーモン起動直後から全ての発話がコマンド扱いに。
active_keys()で初期状態をチェックして、最初の押下イベントを無視するinitially_heldフラグでガードすることで対応 - 「かいぎかい…し」問題: 人間はキーを離してから言い終わることが多いんですが、キーを離した瞬間に command_mode が落ちると、コマンドの最後の音節を逃して発動失敗。キーリリース後 1.5 秒の猶予タイマーを入れて回避
-
i18nの
key引数事件:t("rec.ptt_on", key=...)と書いたら、t()の第一引数keyと衝突して TypeError。キーリスナースレッドだけ静かに死ぬ。録音や文字起こしは動いてるから、しばらく気づけない
ということでPTT周りはエピソード盛りだくさんで、しかも最終的な使い心地もまだいまいちです。
翻訳をどうするか
当初、Claude Codeから使うことを考えていたので、Claude Codeからサブエージェントを立ち上げてファイルを監視し、翻訳をテキストに書き込むとしていましたが、Claude Codeなしでも動いたほうがよいよなぁ、と選択肢をGeminiで探したところ、LibreTranslateというものが見つかりました。ローカルで動きます。精度はそこそこということのようですが、スピードは速いです。
また、手元で遊んでるLLMサーバがあるので、OpenAI 互換APIを指定できるようにもしました。
LibreTranslateは誤認識をそのまま翻訳してしまうので、前段で transformers の Seq2Seq モデルによるスペル訂正を挟むオプション(libretranslate_spell_check)も追加しました。LLMを使うなら文脈判断で直してくれるので不要ですが、ローカルのLibreTranslate単体で使う場合の品質底上げとして用意しています。
ちなみにLibreTranslateには、入力に「AI」みたいな全部大文字の英単語が混ざっていると、出力全体が大文字になるという謎の挙動があって、最初これに気づかず「なんで突然全部叫び始めたの…」となっていました。たとえば「今日のミーティングではAIの活用について議論した」を翻訳すると、Today's meeting discussed the use of AI. ではなく TODAY'S MEETING DISCUSSED THE USE OF AI. が返ってくる、みたいな感じです。
入力側で「AI」を「ai」に置換しても今度は固有名詞情報が落ちるので、結局 出力側でパッチ することに。レスポンスが全大文字だったら検出して、小文字化 → 文頭(. ! ? の後)を大文字化 → 「I」の単独使用だけ復元、という地道な後処理を挟んでいます。
ダッシュボード
当初はターミナルに流れる文字列を眺めていました。リアルタイム表示自体はターミナルでもできていたんですが、どう考えてもブラウザで見た方が見やすいよなーということでダッシュボードのUIを作成。過去の会議を遡ったり、マイク(自分)とスピーカー(他者)で色分けしたり、翻訳と並べたりと、見やすさが段違い に良くなりました。
画像は実際の画面で、左から 文字起こしの一覧(日付/会議/検索) / transcript(日本語、話者色分け) / translation(英語翻訳) / summary(日本語要約)の4ペイン構成。上部に会議一覧・検索・コマンドボタンが並んでいます。ちなみに、会社の勉強会でワールドトリガーを紹介(元ネタ)しているときの文字起こしです。翻訳と要約は自宅で動いているgoogle/gemma-4-26B-A4B-itで再生成しました。もともとは、Qwen/Qwen3-Coder-30B-A3B-Instruct-FP8使ってましたが、Gemma-4のほうが品質は良くなりました。
余談ですが、Ternary Bonsaiとかでも試してみましたが、ちょっと厳しかったですね。同じ文章が繰り返されるなどの暴走をしていました。一行一行の翻訳くらいなら行けそうな感じでしたが、まとめて翻訳するのは難しいですね。翻訳はそれようのチューニングを入れれば使えるとは思います(一度に渡すテキスト行数を設定できるようにするとか)。でも、要約は難しいかもしれませんね。
ダッシュボードを作ると色々やれることが増えて、元々Claude Codeの/コマンド経由で色々する想定でしたが、ダッシュボードのUIからできるようになりました。
トランスクリプトの削除や、特定範囲を会議にしたりなどができるようになりました。要約はリアルタイムではなく、会議終了時の自動生成か、UIのボタンからオンデマンドで生成する形です。また、文字起こしが確定する前の中間文字起こしなどもできるようになりました。
ダッシュボード周りで地味にハマったのが、左ペインの開閉UI。最初はパネルが閉じている時にCSSで hidden を当てていたんですが、そうするとペイン自体と一緒に開閉ボタンも消えるので、一度閉じたら二度と開けないという罠に気づかず実装してしまいました。Claudeに「開閉ボタンが見えないけど」と何度も言い直して、最終的にパネル境界に常駐するシェブロンボタン(◄/►)に落ち着きました。
この程度なら雑な指示でも行けるかなぁと思いましたが、手こずることはありますね。今、思いましたが、先に開閉の実装のサンプルを作らせて、その後に実装してもらったほうが良かったかもしれない。別の開発ではそういうことを良くやっていました。
このへんで CLAUDE.md を書き始めた
ここまで紹介してきた「pgiepをtypo扱いして勝手に修正」「__init__ で config をキャッシュして設定が反映されない」「雑な指示だとUIを変な形に実装する」みたいに、作業が進むほど AIの挙動が怪しくなるタイミング が出てきました。気づいたら、 clerk_daemon.py が 1900行超え、llm_client.py が 1000行超えと肥大化していました。基本的に1ファイルが大きくなるとバグりやすくなりますね。
「これはルール書かないと厳しいな」ということで、ここで CLAUDE.md を導入しました。最初からいれとけよという話ですが、最初からこんなにリッチにするつもり無かったんですよねぇ。こんな感じのことを書いています。
- 1ファイル700行以下に抑える
- DRY原則 と「既存のユーティリティをチェックしてから作れ」
-
make dupcheck(pylint R0801)でコピペ重複を検出 - Dead code removal(使ってない import や unreachable を消せ)
-
型ヒント必須(
from __future__ import annotations固定) -
DDD 的な値オブジェクトを導入(
TranscriptLine/Speaker/MeetingSessionなど、strやdictをそのまま渡さない) -
Known Pitfalls セクション(マルチバイトファイルオフセット、
__init__で config をキャッシュするな、PTTの押しっぱなし問題、等)
ファイル名の正規表現があちこちに散らばる問題
値オブジェクト(DDD)のガイドラインを追加する一番の契機になったのが、transcript のファイル名問題 でした。
このアプリでは、transcript / translation / summary を全部 ファイル名で紐付け しています:
- 日別:
transcript-20260325.txt - 会議セッション:
transcript-202603251059.txt - 会議名付き:
transcript-202603251059@Tech Talk.txt - 翻訳:
transcript-202603251059-ja.txt(-言語コード) - 会議名付きの翻訳:
transcript-202603251059@Tech Talk-ja.txt - 要約:
summary-202603251059.md/summary-202603251059@Tech Talk.md
この命名ルールを解析する正規表現が、最初はダッシュボードのJS、バックエンドのPython、各所にちらばっていました。最初は transcript-\d{8}(\d{4})?\.txt くらいだったのが、会議名に対応した時に @ 付きのパターンを追加、翻訳言語サフィックスに対応した時に -\w{2} を追加、といった具合に、各々の正規表現がバラバラに進化していき、結果として 一箇所で対応したパターンが別の箇所では未対応 という状態が頻発するようになりました。
たとえば、
- ダッシュボードの「会議として切り出し」のときに、既存会議リストに
@会議名付きのファイルが表示されない - 会議名の rename をしたら、transcript ファイルだけリネームされて、translation と summary が追従しない
- 検索結果から特定のtranscriptを開いたら、対応する翻訳ファイルが見つからない
みたいなバグというか、いや、同じ挙動にしてよ、みたいな中途半端な修正が頻発。
これは「ファイル名を str のまま扱っているのが悪い」ということで、TranscriptName という値オブジェクトを導入しました。一本化したところで、ファイル名のパース(日付・時刻・会議名・言語・要約)と、関連ファイルの導出(この transcript の translation / summary / オフセットファイル名はこれ)が全部集約されて、以降ファイル名起因のバグがパタッとなくなりました。
まぁ、それでもたまには無視しますけどね...。
自動会議分割の試行錯誤
「ずっと録音し続けて1日1ファイル」だと長すぎて使いにくいので、会議の区切りを自動検出して別ファイルに切り出す仕組みを入れました。
最初は「N分以上の沈黙のあとの最初の発話を会議の開始とする」というシンプルなルールにしたのですが、これだと「3分黙ってて、ふと一言だけ独り言を言った」みたいなのも会議扱いになってしまいます。実際に動かしてみると、ゴミみたいな1行ファイルが量産されて「これは違うな」となりました。
そこで「会議の開始候補としてマークしたあと、1分に1回以上の発話が3分以上続いた場合に確定する」という二段階判定に変更。さらに会議終了後に空っぽや極端に短いtranscriptは自動で消すようにして、ようやくまともに使えるようになりました。地味に、ルールが複雑化するのを避けたいという気持ちと、現実の会議ってそんなに綺麗に始まらないという事実のせめぎ合いでした。
Google Calendar 連携
アドホックな会議じゃなければ、これが一番良いですね。 カレンダーの予定が始まったら自動で start_meeting を発火、transcript ファイル名も予定名で保存、予定が終わったら自動で end_meeting、という仕組みです。
- ファイル名:
transcript-YYYYMMDDHHMM@予定タイトル.txtのように予定名が付く - スコープは
calendar.readonly(読み取り専用)なので書き込まれる心配なし - 前後のバッファ時間も設定可能(デフォルト: 開始2分前/終了1分後)
- OAuth 2.0 のクライアントIDを Google Cloud Console で作って
clerk-util gcal-authで認証 -
gcal_calendar_idを指定すれば個人カレンダー以外(会社の会議用カレンダー等)も監視可能
週に何個も MTG があるので、これが一番便利ですね。後からtranscriptを遡るときも、ファイル名に予定名が入ってるので「あの会議どこ…」がなくなります。
ちょっとした工夫
誤認識対策としては、
- 用語集を作れるようにした。そこに読みを登録しておいて、LLMに翻訳・要約のヒントにしてもらう
- transcriptの漢字を平仮名にしてから翻訳してもらう。transcriptは誤字が多いので、あえて平仮名にして、LLMには文脈判断で翻訳、要約してもらう。
- ja => ja のように、同言語を翻訳対象にすると、上に書かれた要領で、transcriptの誤りを修正してくれる
- たとえばこんな感じで直ります(実際の
Tech Talkミーティングのtranscript差分):
- そのブログの内容をClodコードに読ませて + そのブログの内容をClaude Codeに読ませて - これ筆者の反省って僕の反省をCODCODEが書いてくれてるんですけど + これ筆者の反省って僕の反省をClaude Codeが書いてくれてるんですけど - それはこの話とは案ま関係ないんでいいです + それはこの話とはあんまり関係ないんでいいです - 犬飼っていうのが六郎に案まりちゃんと教えずに + 犬飼っていうのが六郎にあんまりちゃんと教えずに-
Clodコード/CODCODE→Claude Codeのような技術用語の補正、案ま→あんまりのような口語の誤変換補正など、文脈で判断して直してくれます
- たとえばこんな感じで直ります(実際の
用語集の置換順序問題
glossaryの reading 列を使った機械置換、最初は登録順で適用していました。これがしばしば誤爆します。
たとえば reading に「エーアイ → AI」と「オープンエーアイ → OpenAI」の2つを登録していたとき、登録順で前から照合すると:
入力: オープンエーアイの仕様について
↓ "エーアイ" にマッチして置換が走る
出力: オープンAIの仕様について
と、長い方が試される前に短い方が部分マッチして詰みます。
対応は単純で、reading の長さで降順ソートしてから順に試す だけ。長い候補を先に当てて、マッチしたら確定。これで オープンエーアイ → OpenAI が正しく当たるようになりました。LLMに渡すヒント側でも同じ問題があるので、プロンプトに「上から順に照合してください」と明記する対応も入れています。
話者を分析する(無理)
当たり前ですが、Mic(自分)とSpeaker(相手)にしか分類できない。これをなんとか「Aさん」「Bさん」と分離したくて、このプロジェクトで一番時間を溶かしたのがここでした。
採用したのはspeechbrainのECAPA-TDNNモデル。発話区間ごとに192次元の声紋ベクトルを取って、コサイン類似度で「これは前と同じ人か?」を判定する、というオーソドックスな方針です。pyannote-audioも候補でしたがHugging Faceのトークン+ライセンス同意が要るので避けました。
図にするとこんな感じ:
時間で細切れにして声紋を取り、既知のプロフィールと比べて同じ人か新規かを判定、新規ならプロフィールに登録する、という概念自体はシンプルです。例えばこんなイメージ:
| 発話 | 声紋 | 判定 |
|---|---|---|
| おつかれさまです | (a) | 新規: Aさん |
| おつかれさまです | (b) | 新規: Bさん |
| 今日はいい天気 | (a') | (a) に 80% 類似 → Aさん |
→ transcript には [相手A] おつかれさまです / [相手B] おつかれさまです / [相手A] 今日はいい天気 というラベルが付く、という算段。問題は、これを現実の音声相手に動かすと色々とうまくいかない、というところでした。
インストール自体にもひと悶着あったのですが、なんとか終わって、いざ動かすと:
- パラメータをゆるくすると「全部違う人扱い」(1会議で100人以上検出される)
- 厳しくすると「全員同じ人扱い」(相手が2人しかいないのに1人にまとめられる)
- ウィンドウ分割や無音閾値、MeCabによる文末判定、文字数比率でのマッピング…と試行錯誤
別の問題として、話者分離専用にWhisperの別インスタンスを立てる設計にしていたんですが、これがいつの間にかメインのreazonspeech k2を読みに行っていたことが判明。reazonspeech k2は単語単位のタイムスタンプを返さないので、声紋を切り出す音声区間が取れず、結果として何も分離されない、という事態に。結果、諦めました。
サーバをおいて、みんなにインストールしてもらえれば良いんですけど、そんな大げさなことはしたくない。これは引き続き宿題です(…が、きっとやらない)。
後悔
しかし、どう考えてもデスクトップアプリの方が良かったなと…
人に使ってもらおうとすると、PipeWireやfaster-whisper、reazonspeechの依存や、自動起動の設定など、それなりに手順があります。一個ずつやれば詰むような面倒さではないんですが、1バイナリでインストールできるならそれに越したことはないよなぁ、と。ブラウザから見るのもある意味面倒ではあるし。LibreTranslate や LLMサーバはどのみちいるので別としても、クライアント側くらいは気軽に渡せる形にしたかったところです。
使った技術たち
このアプリで使っている技術をざっと列挙するとこんな感じです:
| 領域 | ライブラリ / ツール |
|---|---|
| 音声キャプチャ | PipeWire / PulseAudio, sounddevice, webrtcvad |
| 音声認識 (ASR) | faster-whisper, reazonspeech k2, kotoba-whisper |
| 翻訳 | LibreTranslate, OpenAI互換API (Ollama/vLLM等), transformers (LibreTranslate用スペル訂正) |
| 要約・LLM | OpenAI互換API, claude -p
|
| 話者分離(挫折) | speechbrain (ECAPA-TDNN), pyannote-audio(検討のみ) |
| キー入力(PTT) | pynput (X11), evdev (Wayland) |
| Webサーバ | Python ThreadingHTTPServer + Server-Sent Events |
| 外部連携 | Google Calendar API |
| 開発環境 | Python 3.12+, uv, mypy, pylint, Mermaid, Marp |
PipeWire も webrtcvad も ECAPA-TDNN も SSE も、「名前は聞いたことはあるけどよく知らない、もしくは全然しらない」レベルからのスタートでした。知らない技術を組み合わせて一発で動くアプリが作れてしまう、というのは改めてすごい時代だなと思います。
感想
まず嬉しい誤算として、本人(僕)が一番恩恵を受けた という話があります。僕の働き方はリモートワークが 80-90% で、gather や Meet を多用しています。突発的なリモートミーティングもよくありますが、歳のせいか言ったことや聞いたことをすぐ忘れる。全ての会話が自動で記録されてしかもブラウザから遡れる、というのはなかなか良いです。他人のために作り始めたはずが、一番使っているのは自分、というやつでした。
で、全体として、これくらいのアプリを今まで自分で書こうなんて思うことはなかったです。リアルタイム音声認識なんて、ちゃんと調べたことはないんですが、なんとなく「難易度高そう」というイメージがあって、自分で手を出す対象としては完全に視界外でした。というか、技術リストでも書きましたが、実際、知らない技術も数多くあった です。
それがとても簡単に(というと、ちょっと語弊があるけど)できてしまう。将来的にはアプリというのは、全部カスタムメイドな自分(自社)専用のアプリになってしまい、いわゆるコンシューマー向けのスタンドアロンなアプリはなくなっちゃうんじゃないのかなぁという気までしてしまいます(妄想)。
いや、別にそうなってほしいわけでもなんでもないんですけど、ね。

