声は言葉より早い ── OpenAI gpt-audioの怒り判定が、テキスト判定より2発話先に警報を出すまで

声は言葉より早い ── OpenAI gpt-audioの怒り判定が、テキスト判定より2発話先に警報を出すまで AI

コールセンターの通話から顧客の怒りをリアルタイム検知するシステムを作っている。文字起こしをLLMで判定するテキスト経路は既に動いていて、バックテストも通った。そこに声のトーン判定を足したら、狙っていなかった現象が出た。

声は、言葉より早く怒る。

合成した怒り通話で、テキスト判定が警報圏(75)に達する2発話前に、声の判定は既に72に入っていた。この記事は、その実測に至るまでの2つの失敗——窓の切り方で差が消えた話と、TTSでは作れない素材があった話——を含めた記録である。

なぜテキストだけでは足りないか

前提として、電話帯域(約300Hz〜3.4kHz)では子音の識別に効く高域が物理的に存在しない。実測では、この録音の3〜4kHz成分は全エネルギーの2.3%しかなかった。「ぶっころ」と「ひっこ」が本質的に紛らわしい世界で、罵倒語の正確な聞き取りに依存する怒り検知は脆い

一方、トーン(韻律・音量・ピッチ)は帯域制限に強く残る。そこで相手の音声を gpt-audio に聴かせ、声の性質だけから判定させる経路を足した。プロンプトの核心はここになる。

_SYSTEM = """あなたはコールセンターの通話音声を聴いて、話者(顧客)の**声の調子**から
怒りの度合いを判定する。

**判断材料は声だけにすること。** 語句の意味や丁寧さではなく、声量・話速・語気の鋭さ・
声の震え・抑揚の平坦さ・語尾の強さといった音の性質から判断する。丁寧な言葉づかいでも
声が刺々しければ高く、乱暴な言葉でも笑い混じりで穏やかなら低く付ける。

コストは音声の秒数ぶん課金されるので常時は回さない。テキスト判定が45(閾値70より低いトリガ値)に届いたときだけ、直近の音声を切り出して1回聴かせる。安い信号系で網を張り、高価な解析装置は必要な呼にだけ落とす——電話網の信号系と通話路の関係と同じ設計である。

失敗1: 実時間の窓では差が消えた

最初の実装は「直近12秒の音声を切り出して聴かせる」だった。素直な設計に見えるが、対照実験がこれを殺した。

台詞が一字一句同じで、口調だけ違う通話をTTSで2本合成した(穏やかなcalm版と、怒鳴るloud版)。テキスト判定は同じスコアを出すはずなので、差が出ればそれは声からしか来ない——という実験設計である。

結果、APIを直接叩けば25点差が出るのに、パイプラインを通すと差が消えた(calm 55 vs loud 60)。

原因は窓の中身だった。12秒の実時間窓には、無音と、複数の発話の断片が混ざる。相手が黙っている間(こちらが喋っている間)は無音で埋まるから、calmとloudの窓はどちらも「無音多め+似た構成」の材料になり、均されてしまう。

判定材料が、判定対象と一致していなかったのである。

修正: 発話区間で切る

幸い、座標は既に持っていた。文字起こしのVADが返す audio_start_ms / audio_end_ms を、発話ごとの頭出し再生のために保存してあったのだ。同じ座標で判定材料を切り出せば、「何を判定したか」が文字起こしと1対1で揃う。

def collect_spans(
    messages: list[dict],
    target_sec: float = VOICE_SPAN_TARGET_SEC,
    max_spans: int = 4,
) -> list[tuple[int, int]]:
    """判定材料にする発話区間(ms)を、新しい方から目標長ぶん集める。

    VADが返す audio_start_ms/end_ms(頭出し再生と同じ座標)を使う。
    実時間の窓で切ると無音と複数発話で材料が均されてしまい、calm/loud の
    対照実験で差が消えた(2026-08-03)。発話そのものを積むことで、
    「何が判定されたか」が文字起こしと1対1で揃う。
    """
    spans: list[tuple[int, int]] = []
    total = 0.0
    for m in reversed(messages):
        if m.get("speaker") != "customer" or not m.get("final"):
            continue
        a, b = m.get("audio_start_ms"), m.get("audio_end_ms")
        if a is None or b is None or b <= a:
            continue
        spans.append((int(a), int(b)))
        total += (b - a) / 1000
        if total >= target_sec or len(spans) >= max_spans:
            break
    spans.reverse()
    return spans

音声バッファ側は「通話開始からの絶対時刻」で切り出せるようにした。音声は黙っている間も無音が流れ続けるので、総サンプル数がそのまま通話タイムラインになる。

    def extract_ms(self, start_ms: int, end_ms: int, pad_ms: int = 300) -> np.ndarray:
        """発話区間を絶対時刻で切り出す(前後に少し余白。頭出し再生と同じ流儀)。"""
        a = max(int(SOURCE_RATE * (start_ms - pad_ms) / 1000), self.start_sample)
        b = min(int(SOURCE_RATE * (end_ms + pad_ms) / 1000), self._total)
        if b <= a:
            return np.zeros(0, dtype="<i2")
        off = self.start_sample
        return self._buf[a - off : b - off].copy()

結果: 声がテキストを先行する

窓を発話区間に変えて、合成の怒り通話(段階的にエスカレーションする台本)を流し直した。テキスト判定(T)と声の判定(V)を発話ごとに並べる。

発話 T V
確認ってさっきもそう言いましたよね? 35 65
いつまで待たせるんですか? 45 72
謝れば。 45 75
済むと思ってんのか。 75 78
責任者を出せよ。今すぐ。 85 78

テキストが75に達するのは「済むと思ってんのか」の時点だが、声はその2発話前、「いつまで待たせるんですか?」で既に72に入っている。言葉がまだ敬語の形を保っている間に、声はとっくに怒っていた。

人間の声(自分で演じた実架電の録音)でも確認した。V は 35→45→65→72→78→82 と滑らかに追跡し、ピークはテキスト最大の75を上回った。負例(「もしもし」「はい」だけの通話)では、材料が足切り(5秒未満)に掛かって判定自体が走らず、誤検知も課金もゼロだった。

早く気づくことが目的の製品にとって、これは「トーンでしか分からない怒りがある」より強い結論である。トーンは、言葉より先に教えてくれる。

設計への反映: 早い方を警報に繋ぐ

先行検知が取れても、アラートがテキスト起点のままでは意味がない。声が72でもテキストが45なら鳴らない——せっかくの2発話が捨てられる。そこで両者を混ぜて1つの値にした。混ぜ方は2つ判断がある。

1. 平均ではなくmax。 T45/V72の平均は58で閾値(70)未満のまま。早期信号を薄める混ぜ方は目的に反する。検知は敏感側に倒す。

2. 同じ発話に付いた声だけを混ぜる。 古い発話の声72を混ぜ続けると、相手が落ち着いてもゲージが下がらない。ゲージが下がらなければ、オペレータに「もう平常運転に戻っていい」を伝えられない。

/** 表示用の融合値。**同じ発話に付いた声だけ**を混ぜる(高い方を採る)。
    古い発話の声を混ぜ続けると、相手が落ち着いてもゲージが下がらなくなる。
    maxにするのは、実測で声がテキストを先行する(早期検知が目的)ため */
export function fusedScore(s: Situation): number | null {
  if (s.voice && s.voice.itemId != null && s.voice.itemId === s.lastItemId) {
    return Math.max(s.score ?? 0, s.voice.score);
  }
  return s.score;
}

声の判定にもアラートを持たせた。

    return {
        "score": score,
        "tone": (result.get("tone") or "").strip(),
        "seconds": round(pcm.size / SOURCE_RATE, 1),
        # 声だけで閾値を超えたらアラート。実測で声はテキストを先行するため、
        # テキスト側の発火を待つと検知が2発話ぶん遅れる
        "alert": score >= ANGER_THRESHOLD,
    }

これで画面には「⚠ 相手が強い怒りを示しています(82) — 声のトーン: 声量が大きく、語気が鋭く詰めるような調子で強い怒りが感じられる」のように、声起点であることが分かる形で警報が出る。

失敗2: TTSは「静かな怒り」を演じられない

正直に書いておくべき限界が一つある。対照実験の素材として、calm(穏やか)とcold(静かに抑えた怒り)を同一台詞で合成したのだが、この2本は音響的にほぼ同一だった。有声率は共に24%、RMS差は1dB、基本周波数のレンジもほぼ重なる。

TTSは出力音量を正規化するため、「怒鳴る」という指示は語気の演技には反映されても、物理的な音圧差にはならない。「大声の怒り」は合成できるが、「凍った声の怒り」は合成できない。cold系の検証素材が要るなら人間の録音が必要で、これはTTS合成による検証基盤の明確な限界である。

まとめ

  • 電話帯域では子音が落ちる。罵倒語の聞き取りに依存する怒り検知は脆く、トーンは帯域制限に強い
  • 音声判定の材料は実時間の窓ではなく発話区間で切る。無音と複数発話が混ざると判定が均される。「何を判定したか」を文字と1対1に揃えること
  • 実測の結論: 声は言葉より早い。テキストが閾値に達する2発話前に、声は警戒圏に入っていた
  • だから融合はmaxで敏感側に、ただし同じ発話の声だけ(下がるべきときに下がるゲージを守る)
  • TTS素材には限界がある。「静かな怒り」は合成できない

怒りの検知は「何を検知するか」の勝負だと思っていたが、実装してみると「いつ検知するか」の勝負だった。そして「いつ」を最も早く教えてくれるのは、言葉ではなく声だった。

コメント

タイトルとURLをコピーしました