コールセンターの通話から相手の怒りを検知するシステムを作っている。判定はできた。だが検証で詰まった。
怒った通話が要る。しかし自分で架けて「怒れ」と言われても怒れない。
一人二役で電話に出て、自分で自分に怒鳴る。やってみると分かるが、これは驚くほど難しい。演技は嘘くさくなり、毎回違う喋りになるので前回との比較ができない。しかも実架電のたびに国際通話料がかかる。
そこで通話そのものを合成することにした。TTSで音声を作り、Amazon Connectが吐くのとバイト単位で同じ形式のコンテナに詰めて、システムに流し込む。受話器は要らない。
そして、その合成通話が実架電では見つけられなかったバグを一発で炙り出した。それも「怒りが高まる場面ほど検知が抜ける」という、性質の悪いやつを。
何を合成するのか
このシステムの音声経路はこうなっている。
合成で狙うのは KVS → 消費サービス の入口だ。ここに実機と同じバイト列を流し込めれば、その先の文字起こし・怒り判定・画面表示は実架電と完全に同じコードパスを通る。
ここで手を抜くと検証にならない。KVSがよこすMKVには実機特有の癖がいくつもあり、それを再現しないと「試験は通るが本番で落ちる」ことになる。
癖その1: CodecIDが嘘をついている
このプロジェクトで最初にぶつかった壁がこれだった。KVSのMKVは CodecID に A_AAC と書いてある。だが中身はAACではなく、生のL16 PCM(8kHz / 16bit / mono)だ。素直にAACデコーダに渡すと当然死ぬ。
合成側では、この詐称ごと再現する。
def _track_entry(number: int, name: str) -> bytes:
return _elem(
b"\xae",
_elem(b"\xd7", bytes([number])) # TrackNumber
+ _elem(b"\x53\x6e", name.encode()) # Name
# CodecIDは実機と同じく "A_AAC" を名乗らせる(中身は生L16のまま)。
# 詐称ごと再現しておかないと、実データで起きる問題が試験で出ない
+ _elem(b"\x86", b"A_AAC"),
)
正直に A_PCM/INT/LIT と書いた綺麗なMKVを作るのは簡単だ。だがそれは実機と違う。テストデータを実機より綺麗にした瞬間、テストは意味を失う。
癖その2: 話者が最初から別トラック
Connectは通話を2トラックに分けてくれる。AUDIO_TO_CUSTOMER(こちらの声)と AUDIO_FROM_CUSTOMER(相手の声)だ。話者分離が要らないのはこのシステムの設計上の大きな利点で、合成でも当然2トラックにする。
def build_mkv(customer: np.ndarray, agent: np.ndarray) -> bytes:
"""話者別PCMを、KVSが出すのと同じ構造のMKVに詰める。"""
n = max(customer.size, agent.size)
cust = np.pad(customer, (0, n - customer.size))
agnt = np.pad(agent, (0, n - agent.size))
tracks = _elem(
b"\x16\x54\xae\x6b",
_track_entry(1, "AUDIO_TO_CUSTOMER") + _track_entry(2, "AUDIO_FROM_CUSTOMER"),
)
step = RATE * 20 // 1000 # 20msずつ交互に積む(実機と同じ粒度)
blocks = []
for i in range(0, n, step):
blocks.append(_simple_block(1, agnt[i:i + step].tobytes()))
blocks.append(_simple_block(2, cust[i:i + step].tobytes()))
cluster = _elem(b"\x1f\x43\xb6\x75", _elem(b"\xe7", b"\x00") + b"".join(blocks))
# EBMLヘッダは中身を見ていないので最小限。Segmentの下にTracksとClusterを置く
header = _elem(b"\x1a\x45\xdf\xa3", _elem(b"\x42\x82", b"matroska"))
return header + _elem(b"\x18\x53\x80\x67", tracks + cluster)
20msずつ交互に積むのも実機に合わせている。パーサ側はブロック単位で処理を進めるので、粒度が違うとバッファリングの挙動が変わってしまう。
EBMLの要素はこの2つのヘルパで組み立てている。可変長整数のマーカービットを立てるだけの、身も蓋もない実装だ。
def _vint(value: int, length: int) -> bytes:
"""EBMLの可変長整数(サイズ用。先頭にマーカービットを立てる)。"""
return (value | (1 << (7 * length))).to_bytes(length, "big")
def _elem(elem_id: bytes, payload: bytes) -> bytes:
return elem_id + _vint(len(payload), 8) + payload
癖その3: 電話は音が悪い
ここが一番の落とし穴だった。OpenAIのTTSは24kHzで、非常に綺麗な音声を返す。これをそのまま使うと検証にならない。
電話帯域は約300Hz〜3.4kHzしかない。以前この録音のスペクトルを実測したところ、3〜4kHzの成分は全エネルギーの2.3%しかなかった。子音の識別に効く高域が物理的に存在しないため、「ぶっころ」と「ひっこ」のような対は本質的に紛らわしくなる。
TTSの綺麗な音のまま試験すれば文字起こしは当然よく当たる。そして本番で外す。だからわざと音を悪くする。
def to_telephone_band(x: np.ndarray) -> np.ndarray:
"""24kHz → 8kHz。折り返しを防ぐため先に低域だけ残す。
電話帯域に落とすこと自体が検証の一部。TTSの綺麗な音のままでは、
実際の通話で起きる聞き取り困難を再現できない。
"""
taps = np.hamming(61) * np.sinc(np.arange(-30, 31) * 2 * 3400 / TTS_RATE)
taps /= taps.sum()
filtered = np.convolve(x.astype(np.float64), taps, mode="same")
return np.rint(filtered[::3]).clip(-32768, 32767).astype("<i2")
3.4kHzで切ってから3サンプルに1つ間引く。ローパスを先にかけないと折り返し雑音が乗るが、それは電話とは別種の劣化なので、正しく落としてから間引く。
台本
怒りの判定は発話1個ではなく直近数発話のウィンドウで見ている。「もしもし」単体に感情は乗らないし、怒りは流れの中で立ち上がるからだ。したがって台本も、単発の罵倒ではなくエスカレーションの流れにする必要がある。
SCRIPT: list[tuple[str, str, str]] = [
# (話者, 台詞, TTSへの口調指示)
("agent", "解析を開始しました。ご用件をお話しください。", "落ち着いた案内の口調で"),
("customer", "もしもし、先週から言ってる件、どうなってますか。", "普通の問い合わせの口調で"),
("agent", "確認いたしますので少々お待ちください。", "丁寧な事務的口調で"),
("customer", "確認って、さっきもそう言いましたよね。いつまで待たせるんですか。",
"少し苛立った、語気の強まった口調で"),
("agent", "申し訳ございません。", "恐縮した口調で"),
("customer", "謝れば済むと思ってんのか。あんたじゃ話にならん、責任者を出せよ今すぐ!",
"強い怒りをぶつける、大きな声で"),
]
第3要素の口調指示が効く。gpt-4o-mini-tts は instructions で演技を指定でき、同じ台詞でも「恐縮した口調」と「怒りをぶつける口調」で明確に別物になる。人間には難しい「怒りの演技」を、機械には指示一行で頼める。
話者をまたいだ時間軸の管理はこうしている。喋る側に音を置き、相手側には同じ長さの無音を置く。
for speaker, text, tone in SCRIPT:
voice = args.customer_voice if speaker == "customer" else args.agent_voice
pcm = to_telephone_band(synth(text, tone, voice, key))
other = "agent" if speaker == "customer" else "customer"
# 喋る側にはこの位置から音を置き、相手側には同じ長さの無音を置く
tracks[speaker].append((cursor, pcm))
tracks[other].append((cursor, np.zeros(pcm.size, dtype="<i2")))
cursor += pcm.size + gap
これで両トラックの長さが常に一致する。実機のKVSも同様に、喋っていない側には無音が流れ続けている。
そして、バグが出た
合成した通話を流した。結果は max_anger 45。閾値の70に届かない。
台本を見れば分かるが、最後は「謝れば済むと思ってんのか。あんたじゃ話にならん、責任者を出せよ今すぐ!」である。これが45で済むはずがない。判定器がおかしいのか、と思って結果を1発話ずつ見た。
[ 10] customer もしもし
customer 先週から言ってる件、どうなってますか?
[ 35] customer 確認って、さっきもそう言いましたよね?
customer いつまで待たせるんですか。
[ 45] customer 謝れば ← ここで判定が止まっている
customer 済むと思ってんのか。 ← 無判定
customer あんたじゃ話にならん。 ← 無判定
customer 責任者を出せよ、今すぐ。 ← 無判定
判定器は間違っていなかった。一番きつい3発話が、そもそも判定されていない。
犯人はデバウンスだった。判定はLLMを叩くのでコストと頻度を抑える必要があり、こう書いていた。
def should_judge(self, msg: dict) -> bool:
"""相手の確定発話だけを対象にし、間隔を空ける。"""
if msg.get("speaker") != "customer" or not msg.get("final") or not msg.get("text"):
return False
if self._busy:
return False
return (time.monotonic() - self._last_at) >= ANGER_MIN_INTERVAL_SEC
判定中(_busy)なら諦める。前回から一定時間経っていなければ諦める。ごく普通のデバウンスであり、レビューしても違和感を覚えないだろう。
だが、この設計には致命的な性質がある。
発話が立て込む場面は、怒りが高まっている場面である。
穏やかに話す人は一文ずつ間を置く。怒っている人は畳みかける。つまりこのデバウンスは、検知したい瞬間を狙い撃ちで落とす。しかも落とした側は何も記録に残らないので、画面を見ている限り「判定した結果45だった」としか見えない。
修正は、捨てるのをやめて最新の1件に畳み込むこと。
async def run(self, messages: list[dict], target: dict) -> None:
"""判定して、対象の発話に結果を書き込みブラウザへ流す。
間隔を空けるが**発話は捨てない**。判定中に来たものは最新の1件に畳み込み、
直前の判定が終わり次第それを判定する。捨てる実装にすると、発話が立て込む
場面——つまり怒りが高まっている場面——ほど判定が抜ける。
"""
self._pending = (messages, target)
if self._busy:
return # 走っているループが拾う
self._busy = True
try:
while self._pending is not None:
wait = ANGER_MIN_INTERVAL_SEC - (time.monotonic() - self._last_at)
if wait > 0:
await asyncio.sleep(wait)
msgs, tgt = self._pending
self._pending = None
self._last_at = time.monotonic()
result = await judge(build_window(msgs))
if result is None:
continue
# 発話そのものにスコアを載せる(記録にもそのまま残る)
tgt["anger_score"] = result["score"]
tgt["anger_reason"] = result["reason"]
self.max_score = max(self.max_score, result["score"])
await self._emit({
"type": "emotion",
"speaker": "customer",
"item_id": tgt.get("item_id"),
**result,
})
finally:
self._busy = False
間隔は守る。API呼び出し回数も増えない。だが直近の状態だけは必ず判定に載る。途中経過は間引かれてよく、最新は絶対に落とさない。
この「捨てるか、畳み込むか」の違いは、平常時には見えない。空いているときはどちらも同じ動作をする。混んだときだけ挙動が分かれる。輻輳時にどう振る舞うかを設計しておかないと、忙しいときに限って壊れる——電話網の輻輳制御と同じ話だ。
修正後、同じ素材を流した。
[ 10] customer もしもし
[ 25] customer 先週から言ってる件、どうなってますか?
[ 35] customer 確認って、さっきもそう言いましたよね。
[ 45] customer いつまで待たせるんですか。
[ 45] customer 謝れば
[ 75] ALERT customer 済むと思ってんのか。
[ 75] ALERT customer あんたじゃ話にならん。
[ 85] ALERT customer 責任者を出せよ、今すぐ。
max_anger 85、アラート発火。
実架電で答え合わせ
合成で通ったので、本物でも確かめた。今度は自分で架けて、演技で怒ってみる。合成という「正解」を先に持っていると、演技の下手さは問題にならない。
0 もしもし。
20 ちょっとシステムに欠陥があるんだけどどうなってるの?
45 すげーバグってんだけど。 ← 「罵倒や人格否定はない」と切り分け
75 このバグは異常だよマジで責任者出してくんね。 ALERT
65 どうやって直すんこれ。 ← 下がった
75 責任者出せ、責任者。
75 だから責任者出せって言ってんでしょ。
実呼でも75で発火した。
注目したいのは65で一度下がっているところだ。これは累積スコアではなく「今この瞬間の状態」を見ている証拠になる。怒りが収まったときに下がらないゲージは、オペレータに「もう平常運転に戻っていい」を伝えられず、実用にならない。
もう一つ。この75は罵倒語の正確な聞き取りに依存していない。判定材料は「責任者出せ」の繰り返しと「マジで」であって、聞き取り困難な卑語を経由していない。
これは以前の測定に対する朗報だった。電話帯域では子音が物理的に落ちるため、罵倒語の聞き取りに依存する怒り判定は脆いと考えていた。だが要求の構造で立ち上がる怒り——同じ要求の繰り返し、責任者要求、対応そのものの否定——は帯域制限の影響を受けにくい。想定より頑丈だった。
バックテスト
これでようやく、判定ロジックを触るたびに走らせる回帰テストの素材が揃った。
| 素材 |
種別 |
max_anger |
判定 |
| 実架電の怒り通話 |
正例 |
75 |
発火 |
| 合成の怒り通話 |
正例 |
85 |
発火 |
| 普通の通話 |
負例 |
10 |
— |
| 実架電 他5本 |
負例 |
0〜55 |
— |
怒り通話だけが閾値を超え、誤検知ゼロ。合格である。
得たもの
このバグは、実架電だけで検証していたら本番で怒鳴られた日に初めて分かる類のものだった。しかも「怒りが高まる場面ほど検知が抜ける」という、製品の存在意義を直撃する壊れ方をする。
合成データを作る手間は数時間だった。それで得たものは大きい。
- 人間の演技に依存しない。怒る必要が永久に無くなった
- 同じ素材で前後比較できる。閾値を動かしたときの影響が測れる
- 異常系を狙って作れる。畳みかける喋り方、途中で冷静に戻る流れ、皮肉だけの嫌味——台本を書くだけで再現できる
- タダで何度でも流せる。国際通話料もConnectの番号日額もかからない
そして何より、テストデータを実機より綺麗にしないという原則を守ったことが効いた。CodecIDの詐称も、電話帯域への劣化も、再現しなければ楽に作れた。だが楽をした分だけ、試験は本番から遠ざかっていたはずだ。
「怒れない」という人間側の限界を、工学で回避した話である。
このシステムは crossbar_telepath という個人プロジェクトで、Amazon ConnectをTerraformでフルIaC構築し、通話の両話者をチャンネル分離してリアルタイムに心理状態をモニタリングする。リポジトリは整理でき次第公開する予定。
コメント