結論|速い方法を探すより、壊れた出力を検出できる形にする

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.wav

16kHz のモノラルにするのは、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 分かかって出てきた結果がこれでした。

ラベル発話時間区間数
SPK0014.3 分93
SPK0123.8 分86
SPK020.0 分1

3 つ目のクラスタが実質空で、2 人分しか出ていません。中身を読むと、提案側の 1 人と先方の担当者が同じラベルに入っていました。別人が同じ人として記録されている状態で、この出力をそのまま議事録にすると発言者が入れ替わります。

CoreML は救いにならなかった

遅さの原因が実行プロバイダにあるなら、CoreML へ切り替えれば解決するかもしれません。120 秒のサンプルで測りました。

プロバイダ処理時間RTF
cpu186.6 秒1.555
coreml143.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スペクトラル
224.1 / 15.7 分23.9 / 15.9 分24.2 / 15.6 分
324.1 / 8.3 / 7.4 分23.9 / 8.5 / 7.4 分24.2 / 8.2 / 7.4 分
412.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 分が半分に割れた形で、同じ人を切っているだけです。

手法をまたいで同じ答えが出るかどうかが、指定した話者数が正しいかの判定に使えます。データの構造に沿った分割であれば、アルゴリズムが違っても同じ場所で切れます。逆に手法ごとに答えが変われば、その分割数はデータが支持していません。

FIXITFIXIT

最初から違うやり方を選んでれば、失敗しなかったってこと?

TsukasaTsukasa

そうです。逆に言えば、失敗しなければ埋め込みが正しいことを確かめていません。

FIXITFIXIT
えっ、確かめないと困るの?
TsukasaTsukasa

次に結果を疑ったとき、どこを直せばいいか分かりません。一度切り分けておけば迷わずに済みます。

4. 正しさを検算する

自動処理の結果を「たぶん合っている」で通してはいけません。話者ラベルは、入れ替わっていても文章としては自然に読めてしまうためです。

会議の冒頭には、全員が名乗る瞬間があります。ここが唯一の教師データになります。

クラスタ先頭付近に入っていた発話
C0提案側の技術責任者の自己紹介
C1先方の担当者の自己紹介
C2提案側の営業担当の自己紹介

3 つのクラスタに、別々の人の自己紹介が 1 つずつ入りました。この時点で分割は正しいと判断できます。

先に失敗した方式では、この検算で 2 人分の自己紹介が同じクラスタに入っていることが分かりました。検算の手順を先に決めていたから、失敗に気づけたという順序です。

5. 誤変換は必ず残る

文字起こしの精度は高いのですが、固有名詞と専門用語は音響的に近い一般語へ落ちます。実際に出たものを挙げます。

認識結果正しい表記
性的検査静的解析
旋盤環境本番環境
臨機書稟議書
未健在未顕在
HitHubGitHub
クロードコード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 利用の就業規則を参照してください。

FIXITFIXIT

結局これ、素直にクラウドに投げたほうが早かったんじゃないの?

TsukasaTsukasa

1 本だけなら早いです。判断の軸は、これを毎週やるかどうかでした。

FIXITFIXIT
えっ、毎週だと何か変わるの?
TsukasaTsukasa

送ってよいか考える回数が毎週増えます。手元で完結していれば、その判断自体が要りません。