結論|速い方法を探すより、壊れた出力を検出できる形にする
40 分の商談録画を、音声を一度も外部へ送らずに文字起こしし、どの発言が誰のものかまで割り当てました。手元の Mac だけで完結しています。
本体の処理時間は短いものでした。
| 工程 | 時間 |
|---|---|
| 文字起こし(39 分 58 秒の音声) | 3 分 50 秒 |
| 話者分離 | 37 秒 |
ただしこの数字に到達するまでに、話者分離で 2 回失敗しています。1 回目は 50 分かけて 3 人を 2 人に潰し、2 回目は 37 秒で終わったものの全員を 1 人にまとめました。作業時間の大半はそちらに使っています。
そこで得た判断の軸は、速い方法を探すことではなく、壊れた出力をその場で検出できる形にしておくことでした。話者分離の出力は、一見それらしく見えて中身が入れ替わっていても気づけません。検算の手順を先に決めておかないと、間違った議事録が正しい顔をして残ります。
何に困っていたか|録画は溜まるが、見返せない
商談の録画は自動で溜まります。しかし 40 分の動画を見返す時間は、参加していない人にも参加した人にもありません。要点だけ拾いたいのに、拾うためには全部見る必要がある、という状態でした。
文字起こしすれば解決します。問題は、どこで文字起こしするかです。
| 方法 | 音声の行き先 | 手軽さ |
|---|---|---|
| 議事録 SaaS | 事業者のサーバー | 高い |
| クラウドの音声認識 API | 事業者のサーバー | 高い |
| ローカルで処理 | 手元から出ない | 低い |
商談の音声には、価格・与信・体制・まだ公になっていない計画が、話し言葉のまま入っています。テキストの議事録には書き残さない情報が、削られずに残っているのが録音の性質です。
手元には GEMINI_API_KEY があり、そちらに投げれば数分で済むことも分かっていました。使わなかった理由は精度でも料金でもなく、音声ファイルそのものが外部へ渡ることです。この一点だけで、今回はローカル実行を選びました。
補足
クラウドの音声認識が危険だという話ではありません。契約や規約で学習利用が排除されているサービスは多くあります。ここで避けたのは送信という行為そのものであり、判断が必要な場面を作らないための選択です。
全体の流れ
flowchart LR
A["MP4 録画"] -->|"ffmpeg(再エンコードなし)"| B["M4A 音声"]
B -->|"16kHz モノラル化"| C["WAV"]
C -->|"mlx-whisper"| D["文字起こし JSON<br/>(780 セグメント)"]
D -->|"セグメント境界を再利用"| E["話者埋め込み<br/>(CAMPPlus)"]
E -->|"クラスタリング"| F["話者ラベル"]
D --> G["話者つき議事録"]
F --> G
要点は、話者分離が文字起こしの出力を材料にしている点です。ここが 2 回目の失敗から学んだ設計で、詳細は後述します。
1. 音声を取り出す
動画から音声だけを抜きます。再エンコードすると劣化と時間の両方が発生するため、ストリームをそのままコピーします。
ffmpeg -v error -y -i "商談録画.mp4" -vn -c:a copy -movflags +faststart "商談録画.m4a"40 分の録画で 46MB、処理は数秒で終わります。元は AAC 48kHz ステレオ 160kbps でした。
次に、認識モデルが受け取る形式へ変換します。
ffmpeg -v error -y -i "商談録画.m4a" -ac 1 -ar 16000 -c:a pcm_s16le meeting.wav16kHz のモノラルにするのは、Whisper が内部でこの形式へ落とすためです。先に済ませておくと、後段の話者分離でも同じファイルを使い回せます。
2. 文字起こし
Apple Silicon 向けに最適化された mlx-whisper を使います。インストールを残したくないので uvx で都度実行しました。
uvx --from mlx-whisper mlx_whisper meeting.wav \
--model mlx-community/whisper-large-v3-turbo \
--language ja \
--task transcribe \
--condition-on-previous-text False \
--output-format all \
--output-dir ./out指定のうち 3 つに理由があります。
--language ja は言語自動判定を切るためです。日本語だと分かっているのに判定させると、冒頭の雑音で英語に倒れることがあります。
--condition-on-previous-text False は、長時間音声で同じ文を延々と繰り返すループを防ぐためです。直前の出力を次の推論の文脈に入れる仕組みが、無音や相槌の連続で暴走します。40 分級の音声では切っておくほうが安全です。
--output-format all は、後段で使う JSON を確実に得るためです。テキストだけ欲しくても、セグメントごとの開始秒と終了秒が入った JSON がこの後の主役になります。
実測
M2・メモリ 24GB の Mac での結果です。
| 内訳 | 時間 |
|---|---|
| モデルのダウンロード(1.6GB) | 約 12 分 |
| 文字起こし本体(39 分 58 秒) | 3 分 50 秒 |
出力は約 15,800 文字、780 セグメントでした。実時間のおよそ 10 倍速です。
初回に限っては、ダウンロードのほうが本体の 3 倍長くかかっています。Hugging Face から未認証で取得すると 1.7〜3.3MB/s しか出ず、途中で数分ずつ止まる時間帯がありました。2 回目以降はキャッシュが効くので本体の時間だけになります。
コツ
この待ち時間の性質は、事前に一度モデルを取得しておくだけで消えます。「初回だけ遅い」と分かっていれば、業務で使う前日に空打ちしておけば済みます。
3. 話者分離で 2 回失敗した
文字起こしができても、誰の発言か分からなければ議事録にはなりません。ここからが本題です。
音声は 1 本のステレオミックスで、左右チャンネルの差分は平均 −73dB でした。実質モノラルであり、チャンネルを分けて話者を取り出す手は使えません。区間ごとに「いま話しているのは誰か」を推定する話者ダイアライゼーションが必要です。
失敗 1|正攻法は 50 分かけて 3 人を 2 人に潰した
まず定番の構成を試しました。pyannote のセグメンテーションモデルで発話区間を検出し、各区間の話者埋め込みを CAMPPlus で取ってクラスタリングする流れです。
pyannote の本家は Hugging Face のゲート付きで、アカウントと利用規約への同意が必要になります。認証なしでモデルを取得できる sherpa-onnx 版を選びました。
curl -sL -o seg.tar.bz2 https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-segmentation-models/sherpa-onnx-pyannote-segmentation-3-0.tar.bz2
tar xjf seg.tar.bz2
curl -sL -o campplus.onnx https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/3dspeaker_speech_campplus_sv_zh_en_16k-common_advanced.onnx話者数を 3 と指定して実行し、50 分かかって出てきた結果がこれでした。
| ラベル | 発話時間 | 区間数 |
|---|---|---|
| SPK00 | 14.3 分 | 93 |
| SPK01 | 23.8 分 | 86 |
| SPK02 | 0.0 分 | 1 |
3 つ目のクラスタが実質空で、2 人分しか出ていません。中身を読むと、提案側の 1 人と先方の担当者が同じラベルに入っていました。別人が同じ人として記録されている状態で、この出力をそのまま議事録にすると発言者が入れ替わります。
CoreML は救いにならなかった
遅さの原因が実行プロバイダにあるなら、CoreML へ切り替えれば解決するかもしれません。120 秒のサンプルで測りました。
| プロバイダ | 処理時間 | RTF |
|---|---|---|
| cpu | 186.6 秒 | 1.555 |
| coreml | 143.1 秒 | 1.193 |
23% 速くなっただけでした。RTF が 1 を超えているということは、音声の長さより処理のほうが時間がかかるという意味です。40 分の音声には 40 分以上かかります。
ここで方針を変えました。10 倍速くなるなら乗り換える価値がありますが、23% では性質が変わりません。速くするのではなく、この工程自体を省けないかを考えます。
方式転換|セグメント境界を作り直さない
セグメンテーションモデルがやっているのは、音声を「発話が続いている区間」に切る作業です。そして文字起こしは、すでに 780 個のセグメント境界を出力しています。
話者が交代するとき、人はいったん話し終えます。つまり文の切れ目と話者の切れ目はほぼ一致します。文字起こしが出した境界をそのまま使えば、重いセグメンテーションを丸ごと省けます。
segs = json.load(open("out/meeting.json"))["segments"]
embs, idxs = [], []
for i, s in enumerate(segs):
a, b = int(s["start"] * SR), int(s["end"] * SR)
if b - a < int(0.7 * SR): # 0.7 秒未満は埋め込みが不安定なので除外
continue
chunk = audio[a:min(b, a + 20 * SR)] # 最大 20 秒
st = ext.create_stream()
st.accept_waveform(SR, chunk)
st.input_finished()
e = np.array(ext.compute(st), dtype=np.float32)
embs.append(e / (np.linalg.norm(e) + 1e-9))
idxs.append(i)780 セグメントのうち 0.7 秒未満を除いた 764 個から、192 次元の話者埋め込みを取りました。37 秒で完了しています。50 分が 37 秒になりました。
失敗 2|速くなったが、出力は前より壊れていた
そのまま階層的クラスタリングにかけた結果です。
k=2: C0=39.7分, C1=0.1分
k=3: C0=39.7分, C1=0.1分, C2=0.0分
k=4: C0=39.6分, C1=0.1分, C2=0.0分, C3=0.0分どの分割数を指定しても、ほぼ全体が 1 つのクラスタに入ります。速くはなりましたが、結果は前回より悪くなりました。
ここで切り分けが必要になります。埋め込みが話者を捉えられていないのか、クラスタリングの側が壊れているのか。原因が違えば打ち手も違います。
診断|距離の分布を先に見る
クラスタリングを疑う前に、埋め込み同士のコサイン類似度の分布を出しました。
cos sim: min -0.071 p5 0.264 median 0.471 p95 0.734 max 0.896同一話者なら 0.7 前後、別話者なら 0.3 以下に寄るのが CAMPPlus の性質です。中央値 0.471 で両側へ広く散っており、分布としては話者が分かれています。
念のため、冒頭の自己紹介の音声と、別の時点の発話との類似度を直接見ました。
| 比較対象 | 類似度 |
|---|---|
| 冒頭の自己紹介と、同じ人の発言 | 0.499 |
| 冒頭の自己紹介と、別人の発言 | 0.284 |
埋め込みは正しく働いています。壊れていたのは、クラスタをつなぐ方法のほうでした。
使っていた average linkage は、クラスタ同士の平均距離が近い順に併合していきます。中間的な距離のサンプルが橋渡しになると、そこを伝って全体が 1 つに連鎖します。連鎖効果と呼ばれる既知の弱点です。
会話音声には、相槌・笑い・発話のかぶりが必ず含まれます。橋渡しになるサンプルが構造的に必ず存在するため、この用途で average linkage を選んだこと自体が誤りでした。
3 手法が一致するかを、話者数の判定に使う
結合方法を変え、性質の異なる 3 つのアルゴリズムで同じデータを分けました。
| 分割数 | Ward 法 | k-means | スペクトラル |
|---|---|---|---|
| 2 | 24.1 / 15.7 分 | 23.9 / 15.9 分 | 24.2 / 15.6 分 |
| 3 | 24.1 / 8.3 / 7.4 分 | 23.9 / 8.5 / 7.4 分 | 24.2 / 8.2 / 7.4 分 |
| 4 | 12.9 / 11.1 / 8.3 / 7.4 分 | 12.9 / 11.1 / 8.4 / 7.4 分 | 12.8 / 11.4 / 8.2 / 7.5 分 |
分割数 3 で、3 手法がほぼ同じ分割に収束しました。分割数 4 では最大クラスタが 12.9 分と 11.1 分に割れていますが、これは分割数 3 のときの 24.1 分が半分に割れた形で、同じ人を切っているだけです。
手法をまたいで同じ答えが出るかどうかが、指定した話者数が正しいかの判定に使えます。データの構造に沿った分割であれば、アルゴリズムが違っても同じ場所で切れます。逆に手法ごとに答えが変われば、その分割数はデータが支持していません。
FIXIT最初から違うやり方を選んでれば、失敗しなかったってこと?
Tsukasaそうです。逆に言えば、失敗しなければ埋め込みが正しいことを確かめていません。
FIXIT
Tsukasa次に結果を疑ったとき、どこを直せばいいか分かりません。一度切り分けておけば迷わずに済みます。
4. 正しさを検算する
自動処理の結果を「たぶん合っている」で通してはいけません。話者ラベルは、入れ替わっていても文章としては自然に読めてしまうためです。
会議の冒頭には、全員が名乗る瞬間があります。ここが唯一の教師データになります。
| クラスタ | 先頭付近に入っていた発話 |
|---|---|
| C0 | 提案側の技術責任者の自己紹介 |
| C1 | 先方の担当者の自己紹介 |
| C2 | 提案側の営業担当の自己紹介 |
3 つのクラスタに、別々の人の自己紹介が 1 つずつ入りました。この時点で分割は正しいと判断できます。
先に失敗した方式では、この検算で 2 人分の自己紹介が同じクラスタに入っていることが分かりました。検算の手順を先に決めていたから、失敗に気づけたという順序です。
5. 誤変換は必ず残る
文字起こしの精度は高いのですが、固有名詞と専門用語は音響的に近い一般語へ落ちます。実際に出たものを挙げます。
| 認識結果 | 正しい表記 |
|---|---|
| 性的検査 | 静的解析 |
| 旋盤環境 | 本番環境 |
| 臨機書 | 稟議書 |
| 未健在 | 未顕在 |
| HitHub | GitHub |
| クロードコード | Claude Code |
| セールスコース | Salesforce |
いずれも読めば気づく種類の誤りです。文脈から復元できるため、整形の段階で直せます。
危険なのは人名と社名です。間違っていても間違いだと気づけないため、そのまま議事録に残ります。今回も参加者の 1 人の姓が別の字で出力されていました。参加者の名前だけは事前に辞書として持ち、機械的に置換するほうが安全です。
フィラーの除去、句読点の付与、固有名詞の補正は、話者ラベルが付いた状態で LLM に通します。誰の発言かが保たれているので、発言者を混ぜずに整形できます。
何が得られたか
話者ラベルが付くと、文字起こしだけでは出てこない数字が出ます。
| 話者 | 発話時間 | 発話回数 | 1 回あたり |
|---|---|---|---|
| 提案側の技術責任者 | 24.2 分 | 27 回 | 54 秒 |
| 先方の担当者 | 8.3 分 | 53 回 | 9 秒 |
| 提案側の営業担当 | 7.5 分 | 35 回 | 13 秒 |
40 分のうち 31.7 分、全体の 79% を提案側が話しています。1 回あたり 54 秒という長さは、聞き手が質問を挟みにくい長さです。
これは印象ではなく計測値です。「今日は喋りすぎたかもしれない」という感想と、「79% を占有し、1 発話が平均 54 秒だった」という数字は、次の行動に与える影響が違います。
この数字を営業の振り返りにどう使うかは、AI で強い営業組織をつくるで扱っています。
残っている限界
Whisper のセグメント境界を流用する方式には、構造的な代償があります。境界をまたいで話者が交代した箇所では、短い相槌が相手の発言ブロックへ巻き込まれます。議事録として読むぶんには支障のない程度ですが、発話時間を秒単位で厳密に扱う用途には向きません。
参加人数を事前に指定する必要もあります。今回は 3 人と分かっていたので分割数を 3 に固定しましたが、人数が不明な会議では分割数の決定が別の課題になります。前述の 3 手法の一致を指標にすれば推定はできますが、その分だけ計算は増えます。
まとめ
| やったこと | 判断 |
|---|---|
| ローカルで文字起こし | 音声を外部へ送る判断そのものを発生させない |
--condition-on-previous-text False | 長時間音声で繰り返しループが起きるため切る |
| セグメンテーションを省く | CoreML で 23% では性質が変わらない。工程ごと外す |
| Whisper のセグメント境界を再利用 | 50 分が 37 秒になる。話者の切れ目と文の切れ目はほぼ一致 |
| average linkage をやめる | 相槌が橋渡しになり、全員が 1 クラスタへ連鎖する |
| 3 手法の一致を見る | 話者数の指定が正しいかを、アルゴリズムをまたいで検証する |
| 冒頭の自己紹介と突き合わせる | 話者ラベルは入れ替わっても自然に読める。必ず検算する |
ローカルの音声認識は、精度でも速度でもクラウドに見劣りしない水準に届いています。手元の Mac で 40 分の会議が 4 分弱で文字になり、誰の発言かまで分かります。難所は認識のほうではなく、その後の話者分離と、出力が壊れていることに気づく仕組みのほうにありました。
自社で動かすモデルの選び方についてはオープンウェイトモデルを自社に設置する話でも扱っています。生成 AI の業務利用にあたって社内でどう線を引くかはAI 利用の就業規則を参照してください。
FIXIT結局これ、素直にクラウドに投げたほうが早かったんじゃないの?
Tsukasa1 本だけなら早いです。判断の軸は、これを毎週やるかどうかでした。
FIXIT
Tsukasa送ってよいか考える回数が毎週増えます。手元で完結していれば、その判断自体が要りません。
