Jitsi Meetという、個人でたてられるOSSのWeb会議サーバー(Zoomみたいなの)を使ってAI(IBM Watson)と会話しようと。
つまり、Jitsi Meetの会議室で自分が「Good day!」と言えば、AIが「Hello. Good evening」と音声で返事をする環境である。
構成概要
図面を起こすのがかったるいが、手元のPCからIBM Watsonまでの接続経路は以下のようになる。
「PC(Webブラウザ)」→「Jitsi Meet(Jigasi)」→「Asterisk」→「IBM Voice Agent with Watson」→「IBM Watson」
Jitsi MeetサーバーとAsteriskサーバーは、ここ数日いじり倒しているIBM Cloud上の環境を流用。その構築手順は以下。
https://qiita.com/rk05231977/items/d7360724806f10346089
Voice Agent with Watsonを構成する
1.IBM Cloudのカタログから「Voice Agent with Watson」サービスを購入する。残念な事に、無料のライトプランではAsteriskからの接続を許可する設定ができないため有料のプラン(標準で結構)を選択する。
$20.00 USD/Concurrent Connectionsという事だが、同時接続数は10以下に構成することができないため、2万円ぐらいの出費は多分不可避である。貧乏人が気分で試せるものではない。
2.Asteriskサーバーからの接続を許可する。
サービスのインスタンスが出来たら、「リソース・リスト」>「サービス」>「Voice Agent with Watson-xx」を開き、画面右端にある「インスタンス」をクリックする。
「新規IPの追加」をクリックし、AsteriskサーバーのIPアドレスを追加する。
3.エージェントを作成する。
先にクリックしたインスタンスの左隣にある「エージェント」の画面を開き、「エージェントの作成」をクリックする。
名前と電話番号を入力し、「エージェントの作成」をクリックする。
名前は何でもいいがとりあえず「Watson」とかを入力。
電話番号は、後のAsteriskの設定とを合わせる必要がある。多分Voice Agentの他の顧客と被る番号は指定できないんじゃないかと。適当な長さの数字列を指定する。例では「9001」。
エージェントを作成すると、無駄遣いしなければほとんど無料のWatsonのサービス(「WatsonAssistant」、「SpeechToText」、「TextToSpeech」)が合わせて作られる。
4.エージェントを構成する。
STT、TTSの各サービスは日本語対応しているのだが、サンプルで提供されているアシスタントが英語しか解釈しないので、STT、TTSも英語に切り替える。
出来たエージェントの右側メニューより「エージェントの編集」をクリックする。
Speech to Textのモデルを「en-US_NarrowbandModel:~」に変更する。
Text to Speechのモデルを、まあ、例えば、「en-US_EmilyV3Voice:~」とかに変更する。
5.SIPのエンドポイントを取得する。
画面左メニューの「開始」の画面で、Voice AgentにつながるSIPのエンドポイント名を取得する。多分リージョン毎に共通。ダラスだと「sip:us-south.voiceagent.cloud.ibm.com」。有料版だとバックアップ・エンドポイントも使えるらしい。
Asteriskの設定を修正する
先に紹介したAsteriskサーバーに設定を追加する。以下のような感じ。
(ファイルの最後に以下を追加する)
[9001]
type=endpoint
disallow=all
allow=ulaw
auth=9001
aors=9001
[9001]
type=auth
auth_type=userpass
username=9001
password=Password9001
[9001]
type=aor
max_contacts=1
(ファイルの最後に以下を追加する)
exten => 9001,1,Dial(PJSIP/9001,30)
same => n,Hangup()
編集後、Asteriskの構成をリロードする。
# asterisk -rvv
CLI> pjsip reload
CLI> dialplan reload
あと、iptablesで通信制限をかけている場合は以下に差し替える。
169.61.56.226がVAのエンドポイントのアドレスである。
*filter
:INPUT DROP [0:0]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [0:0]
-A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT
-A INPUT -s localhost -j ACCEPT
-A INPUT -s <踏み台サーバーのIPアドレス> -j ACCEPT
-A INPUT -s <Jitsi MeetサーバーのIPアドレス> -j ACCEPT
-A INPUT -s <AsterikサーバーのIPアドレス> -j ACCEPT
-A INPUT -s 169.61.56.226 -j ACCEPT
-A INPUT -s 0.0.0.0/0 -d <Jitsi MeetサーバーのIPアドレス> -p tcp --dport 80 -j ACCEPT
-A INPUT -s 0.0.0.0/0 -d <Jitsi MeetサーバーのIPアドレス> -p tcp --dport 443 -j ACCEPT
-A INPUT -s 0.0.0.0/0 -d <Jitsi MeetサーバーのIPアドレス> -p tcp --dport 5349 -j ACCEPT
-A INPUT -s 0.0.0.0/0 -d <Jitsi MeetサーバーのIPアドレス> -p udp --dport 3478 -j ACCEPT
-A INPUT -s 0.0.0.0/0 -d <Jitsi MeetサーバーのIPアドレス> -p udp --dport 10000 -j ACCEPT
# ip a
# iptables-restore < /etc/iptables/rules.v4
Contact登録プログラムを作る
トリッキーなのはここである。
私自身Asteriskを触り始めて数日のど素人だが、Asterisk、どうしてもRegistrationされているSIPクライアントに対してしか電話を掛けられないらしく、そしてVoice AgentにはSIP REGISTERをする機能がない。
Voice Agent自体はとりあえずSIP INVITEが飛んでくれば何でも応答するようなのだが。
http://www.blueworx.com/wp-content/uploads/2018/08/IBM-Voice-Gateway-Network-Integration-v5.1-DJU.docx
仕方ないので、AstersiskにREGISTERするところは自分で作って何とかする必要がある。
以下をAsteriskサーバー上で実施する。
1.JDKをインストールする。
別にJavaじゃなくてもいいのだが、UDPプログラミングのサンプルがJavaのが多かったためそれで。
# apt-get install openjdk-11-jdk
2.Apache Commons Codecをダウンロードする。
MD5ダイジェストのライブラリを使いたいので。「commons-codec-1.15-bin.tar.gz」をダウンロードし、展開する。
https://commons.apache.org/proper/commons-codec/download_codec.cgi
# wget https://ftp.riken.jp/net/apache//commons/codec/binaries/commons-codec-1.15-bin.tar.gz
# tar xf commons-codec-1.15-bin.tar.gz
3.Registerプログラムを作る。
「pbx.example.com」は自分のAsteriskサーバーの名前にする。
「_user」や「_password」の値も変えるだろう。
「_va_endpoint」は先に入手したVoice AgentのSIPのエンドポイント名(先頭の"sip:"は除く)にする。
キモは「_contact」である。こいつをAsteriskサーバーの9001ユーザーにダイヤルした場合の、実際の通話先としてAsteriskに仕込んでいるのだ。これは正直、「こうしたら動いた」以上の事は無いので、気が付いたら動かなくなっていても文句言えないアレである。
import java.net.*;
import java.util.regex.*;
import org.apache.commons.codec.digest.*;
public class Register {
public static void main(String[] args) throws Exception {
String _pbx = "pbx.example.com";
String _user = "9001";
String _password = "Password9001";
String _va_endpoint = "us-south.voiceagent.cloud.ibm.com";
String _contact = "<sip:" + _user + "@" + _va_endpoint + ">";
byte[] b;
String r;
DatagramPacket p;
DatagramSocket s = new DatagramSocket(null);
s.setSoTimeout(1000);
String register =
"REGISTER sip:" + _pbx + " SIP/2.0\r\n" +
"Via: SIP/2.0/UDP " + _pbx + "\r\n" +
"To: " + _user + " <sip:" + _user + "@" + _pbx + ">\r\n" +
"From: " + _user + " <sip:" + _user + "@" + _pbx + ">\r\n" +
"Call-ID: " + _user + "@" + _pbx + "\r\n" +
"Contact: " + _contact + ";expires=600\r\n" +
"CSeq: 1 REGISTER\r\n" +
"Content-Length: 0\r\n" +
"\r\n";
b = register.getBytes();
p = new DatagramPacket(b, b.length, pbx, 5060);
s.send(p);
b = new byte[1500];
p = new DatagramPacket(b, b.length);
s.receive(p);
r = new String(p.getData());
System.out.println(r);
Pattern rp = Pattern.compile("WWW-Authenticate.*qop=\"auth\"");
Matcher rm = rp.matcher(r);
if (rm.find()) {
System.out.println(rm.group());
}
rp = Pattern.compile("nonce=\"([^\"]*)\",opaque=\"([^\"]*)\"");
rm = rp.matcher(r);
if (rm.find()) {
System.out.println(rm.group(1));
System.out.println(rm.group(2));
}
String username = _user;
String realm = "asterisk";
String password = _password;
String a1 = DigestUtils.md5Hex(username + ":" + realm + ":" + password);
String uri = "sip:" + _pbx;
String a2 = DigestUtils.md5Hex("REGISTER:" + uri);
String nonce = rm.group(1);
String nc = "00000001";
String cnonce = "xyz";
String qop = "auth";
String rr = a1 + ":" + nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + a2;
String response = DigestUtils.md5Hex(rr);
String opaque = rm.group(2);
String register2=
"REGISTER sip:" + _pbx + " SIP/2.0\r\n" +
"Via: SIP/2.0/UDP " + _pbx + "\r\n" +
"To: " + _user + " <sip:" + _user + "@" + _pbx + ">\r\n" +
"From: " + _user + " <sip:" + _user + "@" + _pbx + ">\r\n" +
"Call-ID: " + _user + "@" + _pbx + "\r\n" +
"Contact: " + _contact + ";expires=600\r\n" +
"CSeq: 2 REGISTER\r\n" +
"Authorization: Digest username=\"" + username + "\",realm=\"" + realm
+ "\",nonce=\"" + nonce + "\",uri=\"" + uri + "\",response=\"" + response
+ "\",algorithm=md5,opaque=\"" + opaque + "\",qop=" + qop
+ ",cnonce=\"" + cnonce + "\",nc=" + nc + "\r\n" +
"Content-Length: 0\r\n" +
"\r\n";
b = register2.getBytes();
p = new DatagramPacket(b, b.length, pbx, 5060);
s.send(p);
b = new byte[1500];
p = new DatagramPacket(b, b.length);
s.receive(p);
r = new String(p.getData());
System.out.println(r);
s.close();
}
}
4.プログラムをコンパイルして実行する。
# javac -cp commons-codec-1.15/commons-codec-1.15.jar:. Register.java
# java -cp commons-codec-1.15/commons-codec-1.15.jar:. Register
5.AsteriskのContactsを確認する。
# asterisk -rvv
# CLI> pjsip show contacts
(以下が表示されればOK)
Contact: <Aor/ContactUri..............................> <Hash....> <Status> <RTT(ms)..>
==========================================================================================
...
Contact: 9001/sip:9001@us-south.voiceagent.cloud.ibm.co 3bc8538684 NonQual nan
...
このContact情報、10分経つと消えるため、cronで10分おきにRegisterを実行する。
# echo "*/10 * * * * root java -cp /root/commons-codec-1.15/commons-codec-1.15.jar:/root Register" > /etc/cron.d/register
Jitsi Meet会議室からWatsonにつなぐ
いよいよお楽しみ、Watsonへの接続である。
つなぐのは簡単で、会議室から9001番に電話を掛ければ良い。
接続に成功すれば「Hi, my name is Watson~」と音声が流れてくるはずである。しゃべっている途中に割り込んでもいいので「Hello!」と言ってみよう。
「Sure. Is anything else you would like to know?」と返ってくるだろうか。こちらがしゃべった内容を聞き取って、それに合わせて返事を返している。
ちなみに、音声品質は悪くない。スマホのSIPクライアントからAsteriskにつないだ時はノイズ乗りまくりだったのだが、こちらは原因不明。。
こちらからの発話に対する応答の文章はIBM Cloudの、「VoiceAgent-WatsonAssistant」のサービスで確認することができる。
「Watson Assistant」を起動し、
「VoiceGatewayConversation」を開く。
「Intents」に登録されている文言に、大体返事を返してくれると思っていいだろう。例えば、「How can I buy you」とか。
面白いのが、例えば「I want to talk to Mike」と言えば声色を男性のものに変えることができるし、「I want to speak Spanish」と言えば、会話の途中で言語をスペイン語に変えることもできる(「Change to English」で元の英語に戻る)。
機械相手なので、家族に聞かれでもしなければ英語を喋るのに躊躇しなくていい。これは英語初心者にはありがたいのではないだろうか。
なお、会話を切断するときは画面右下のユーザーのメニューから「追い出す」を選択する。その前に「Bye bye」ぐらいは言っても良い。注意が必要なのは会議室を閉じるだけではWatsonとの電話接続は切れないことである。課金が通話時間で効いてくるところがあるため、話さなくていいときは会議室から追い出す、忘れずに。