Terraformで電話網に「盗聴されない盗聴器」を建てる — crossbar_telepath 開発記 (1)

電話の向こうの相手が、いま苛立っているのか、納得しかけているのか。声のトーンには全部出ているのに、通話中の人間はそれを拾う余裕がない。ならば交換機の脇にAIを座らせて、両者の心理状態をリアルタイムに読ませればいい——そういうプロダクトを作り始めた。

名前は crossbar_telepath。クロスバー交換機(crossbar)+電話線越しの読心(telepath)。前作 realtime_voice の続編である。断っておくと「盗聴器」は比喩で、設計思想はむしろ逆だ。接続時に録音・解析することをアナウンスで宣言し、相手に開示できることを最初から要件にしている。ステルス型の耳打ちAIが炎上した例は既にあるので、その轍は踏まない。

アーキテクチャ: 話者分離を「買う」

全体構成はこうなる。

crossbar_telepathのアーキテクチャ: 公衆電話網→Amazon Connect→KVS→FastAPI消費サービス→OpenAI Realtime API/WebUI

Amazon Connectを選んだ最大の理由は、話者分離という難問が消えることだ。

通話音声から「誰がいつ喋ったか」を分ける話者ダイアライゼーションは、AIで推定する確率的な処理で、誤分離が必ず混ざる。ところがConnectのメディアストリーミングは、自分側と相手側の音声を最初から別トラックでKinesis Video Streams(KVS)に流してくれる。交換機が別線で渡してくれるものを、わざわざ混ぜてから推定し直す理由はない。心理分析の入力品質は、ここで勝負が半分決まる。

PH1: 実質4リソースのTerraform

最初のマイルストーンは「架電すると同意アナウンスが流れ、通話音声がKVSに積まれる」まで。Terraformで書くと骨格は4つしかない。

リソース 役割
aws_connect_instance 局舎。インスタンス本体
aws_connect_phone_number 局番。DID取得
aws_connect_instance_storage_config 通話路をKVSへ分岐するトランク
aws_connect_contact_flow 呼処理シナリオ(IVR)

これに番号→フローの紐付け(aws_connect_phone_number_contact_flow_association)とKMSキーを足して7リソース。エージェント(人間のオペレーター)は一人も作らない。着信はIVRが自動応答するので、受電要員ゼロのコールセンターが成立する。

つまずきは2つあった。

その1: 列挙名がAPIと違う。 メディアストリーミングの設定は、APIリファレンスでは LIVE_MEDIA_STREAMS なのに、Terraformプロバイダの列挙は MEDIA_STREAMS を要求する。エラーメッセージが候補を全部並べてくれたから即死ですんだ。

その2: 電話番号は在庫の取り合い。 terraform apply 一発目は Phone number not available: +1619... で落ちた。検索APIが返した候補番号を、確保する前に誰かに取られたらしい。再applyしたら別の番号があっさり取れた。番号在庫はレースコンディションの世界である。

なお日本の+81番号は書類審査が必要で即日には取れない。検証だけなら米国DIDが即時に取れるので、PH1は+1番号で進めた(日本の携帯から国際発信しても数分の検証なら誤差だ)。

コールフローJSONの考古学

一番時間を食ったのは、意外にもコールフローのJSONだった。

Terraformの aws_connect_contact_flow は新フロー言語(Version: 2019-10-30Actions配列)を要求する。ところが公式ドキュメントの該当ページはリダイレクト地獄で実体にたどり着けず、Web検索で拾えるサンプルの多くは旧形式(modules配列)だった。旧形式はGUIインポート用で、APIには食わせられない。

結局、確実だったのはドキュメントではなく実物だった。AWS公式サンプルのリポジトリと、Deepgram社のConnect連携リポジトリに、動いている新形式フローのJSONがそのまま置いてある。そこから逆算して、ストリーミング開始ブロックの正確なスキーマを確定した。

{
  "Type": "UpdateContactMediaStreamingBehavior",
  "Parameters": {
    "MediaStreamingState": "Enabled",
    "MediaStreamType": "Audio",
    "Participants": [
      { "ParticipantType": "Customer", "MediaDirections": ["To", "From"] }
    ]
  }
}

もう一つ、実物から学んだ定石が「無音SSMLループ」だ。ストリーミングを開始した後、フローが終端に達すると呼が切れてしまう。かといって保留音を流すと録音に混ざる。答えは <break time="5000ms"/> を12個並べた1分間の無音プロンプトを Loop ブロックで回すこと。何も喋らないアナウンスを延々再生して通話を維持する。バカバカしいが効く。

架電テスト: 通話路の開通

自分の携帯から取得番号に架電すると、日本語(Polly Takumi)で同意アナウンスが流れ、そのまま無音保留に入った。適当に喋って切る。

KVSを見にいくと、ストリームが生えていた。

crossbar-telepath-connect-...-contact-af330898-...
MediaType: audio/L16, Status: ACTIVE

list-fragments で中身も確認できた。10フラグメント、323,046バイト、9.0秒。「公衆網 → Connect → KVS」の通話路が、Terraform apply から1時間足らずで開通したことになる。

ffmpegが死んだ: CodecID詐称事件

さて、録れた声を聴きたい。get-media-for-fragment-list でフラグメントを落とすとMKVコンテナが降ってくる。MKVなら ffmpeg -i call.mkv out.wav で終わり——のはずだった。

[aac @ ...] channel element 0.0 duplicate
Error submitting packet to decoder: Invalid data found when processing input

全フレームがデコードエラー。ffprobe はトラックを Audio: aac (LC), stereo と報告している。だがKVSのストリーム情報は audio/L16、つまり生PCMだと言っていた。どちらかが嘘をついている。

MKVをバイナリで開いて確かめると、犯人はコンテナだった。トラックのCodecIDが A_AAC を名乗っているのに、ペイロードは生のL16 PCM(8kHz/16bit/mono)なのだ。ffmpegは律儀にCodecIDを信じてAACデコーダに食わせ、当然ながら全滅していた。詐称しているのはConnect側で、ffmpegは濡れ衣である。

こうなると汎用ツールは使えない。EBML(MKVの下位構造)を自前で歩くしかない。といっても大げさな話ではなく、Pythonの ebmlite でパースして、音声の入っている SimpleBlock の先頭4バイト——トラック番号(1バイト)+相対タイムコード(2バイト)+フラグ(1バイト)——を剥がせば、残りがそのままPCMペイロードだ。トラック番号で振り分けながら連結してWAVヘッダを付ける。60行で書けた。

最後の罠: どっちが誰の声か

抽出した2トラックの音量を測ると、片方は平均-26.9dB(音声あり)、もう片方は-91dB(デジタル無音)だった。ところが当初のトラック対応付けでは、無音側が「発信者の声」になってしまった。俺は確かに喋ったのだが。

無音検出で音声側の構造を見ると、4.7秒の無音のあとに短い発話が2回。これは明らかに人間の喋り方で、アナウンスやガイダンスの波形ではない。つまり発信者の声は生きていて、対応付けが逆なのだ。MKVの Tracks 要素を直接読んで確定した:

  • トラック1 = AUDIO_TO_CUSTOMER(Connect→発信者。今回は保留中の無音)
  • トラック2 = AUDIO_FROM_CUSTOMER(発信者の声)

from_customer.wav を再生すると、8kHzの電話品質で自分の声が返ってきた。両話者がコンテナのトラックレベルで完全に分離された状態で、である。プロジェクトの根幹仮説——話者分離は買える——が実データで裏付けられた瞬間だ。

まとめと次回

PH1の成果物はTerraform一式と抽出ツール、そして知見のほうが大きい。

  • コールフローの新形式JSONは、ドキュメントより動いている実物から逆算するのが確実
  • 電話番号の取得はレースで落ちる。リトライすればいい
  • KVSのMKVはCodecIDを信じてはいけないA_AAC の中身は生L16 PCMで、EBMLを自前で歩けば60行で取り出せる
  • トラック1=TO_CUSTOMER、トラック2=FROM_CUSTOMER。話者分離はコンテナレベルで成立している

検証後は terraform destroy で全リソースを落とした(番号は日額課金なので、触らない日は建てておかない)。次回PH2は、今回アーカイブ経由でやった抽出を GetMedia によるリアルタイム逐次パースに組み替え、話者別の文字起こしがログに流れるところまで。ここまで来れば、Realtime APIに「声のトーン込みで」心理を読ませるPH3が見えてくる。

当初「既知の最難関」と身構えていたKVSからの音声取り出しは、初日に正体を割ってしまった。難所は早めに殴りにいくに限る。

コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

上部へスクロール