2018.4.30
キモヲタだけど、女子高生と会話したい!#16【完成!!!!!!!】
音声合成ですね。まぁここまで来たら特に難しいことはないのですが、難しいのは
どのサービスを利用するかですね。
僕は最初、docomoのSDKを用いようと思ってたんですが、それも結局インターネットに接続する必要があるので、オフラインがいいなと思ってた僕はちょっと渋っちゃいました。あとなんか利用制限みたいなのがあるみたいですし・・・・・・。
でも仕方ないので継続してDocomoで提供されてる
AITalkを使っていこうかなと思います。リクエスト数の上限がわからないというか、どこ見たらいいのか分からない割と不親切なページなのですが、まぁありがたく使わせていただきます。
一応、RESTAPIが提供されていますが、XMLで送信しなくてはならず、その辺がちょっとめんどくさそうというか、Volleyでは対応してないのでライブラリを使わせていただきますかね。
さて、とりあえずSDKを落としてインポートしますかね。ただ、普通にインポートするとAndroid Studioに怒られるので、jarファイルを直接読み込む感じで行こうと思います。それが一番ラクそうなので。
build.gradleに特に何も書き加えなくても、とりあえず(プロジェクトフォルダ)/app/libsにぶっこんで、とりあえずsyncしたら使えるようになりました。
んで、実装です。先に言っておくと
めちゃくちゃハマってます。
一晩で終わるかと思ってたら現時点で優に3日以上はかかってます。
まず、受け取ったPCMデータの使い方がまじで分からずに調べまくったり、SDKを使ってもずっとアプリが落ちるのでなんでやねんと思って例外をキャッチしてみたら
NetworkOnMainThreadExceptionとか投げられてたりで。どうい意味かを調べてみると
メインスレッドで通信とかすんなやヴォケという例外らしいです。
聞いてねえんだよハゲ
それで、回避策を調べてみると
AsyncTaskLoaderとかいうのを使えば回避できるっぽいのでこいつを実装し、そこからどうしてもメインスレッドのメソッドが呼び出せないので、どうしたらいいのかというと
リスナーを実装しておけとのことらしい。
とりあえず
ノイズが再生されるところまでは行ったんですが、ちょっとどうしようもない感じですね。
仕様はたぶん合ってて、SDKのサンプル通りに動かしてるので別に書き方としては問題ないですし、返ってくるバイナリ配列も毎回長さが違うのでたぶん正常にデータは取れてるんじゃないかとは思います。
ただ、もしかすると送信されてる文字コードがアレだったり
(仕様としてはSJISらしい...)するので、そのへんのフローは隠蔽されてるのでもうわかりません。
んでんでんで、この時点で
1週間近くハマってたんですが、ついに解決したので一気に書いちゃいます。
まず音声合成に関してですが
エンディアン変換というものが必要でした。
ここに書いてあるんですが、ビッグエンディアンという記述に関して特に気に留めてなかったんですよね。ただ、どうもAndroidアプリはリトルエンディアンらしく、その変換をすることでノイズが消えました。
あともうひとつ、SDKのマニュアルにこんな感じに書かれてるんですが、
これ嘘が書いてあります。
具体的には、startProsodyメソッドとstartVoiceメソッドの順番を逆にしないと設定が反映されません。理由はわかりません。
ほんとに動いてるコードからマニュアル作ったのか怪しいよね
もうひとつ、日記の記事にはしてないのですが、通信系ライブラリはVolleyを紹介しましたが
原因は不明だが二重に送信されるというバグが発生したので
Retrofit2というライブラリを使いました。なかなか使い勝手が良かったです。
ソースコードは・・・・・・どうしようかなあ。全部公開する理由もないんですが、とりあえずコアとなる処理の部分のコードは公開しますかね。日記ですし。
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
if (ContextCompat.checkSelfPermission(
this,android.Manifest.permission.RECORD_AUDIO)== PackageManager.PERMISSION_GRANTED
&& ContextCompat.checkSelfPermission(
this,android.Manifest.permission.INTERNET)== PackageManager.PERMISSION_GRANTED){
// 許可されている時の処理
setup()
}else{
// 拒否されている時の処理
if (ActivityCompat.shouldShowRequestPermissionRationale(this, android.Manifest.permission.CAMERA)) {
//拒否された時 Permissionが必要な理由を表示して再度許可を求めたり、機能を無効にしたりします。
} else {
//まだ許可を求める前の時、許可を求めるダイアログを表示します。
ActivityCompat.requestPermissions(this, arrayOf(android.Manifest.permission.RECORD_AUDIO,android.Manifest.permission.INTERNET), 0);
}
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
when (requestCode) {
0 -> { //ActivityCompat#requestPermissions()の第2引数で指定した値
if (grantResults.size > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
//許可された場合の処理
setup()
} else {
//拒否された場合の処理
}
}
}
}
あ、そういえばランタイムパーミッションの話をこの前したような気がしますが、
実機だとライブラリが動いてくれませんでした。
原因はわかんなかったので、自前で実装することにしました。自分しか使わないアプリなので拒否された場合の処理とか書いてません。サンプルから全く触ってないのでCAMERAとか見ちゃってますね。まぁ実機ではもう許可しちゃったのでここに飛ぶことはないです。
これをonCreate時にやっちゃっています。
protected fun sendAjax(str:String) {
//if(voicing==true){return}//送信中または発声中なので処理を終える
//voicing=true
Log.i("TEST_TAG", "in_sendAjax")
var retrofit = Retrofit.Builder()
.baseUrl("(ベースとなるURL)")
.addConverterFactory(GsonConverterFactory.create())
.build()
val rinnna = retrofit.create<RinnnaInterface>(RinnnaInterface::class.java!!)
var rinnnaRequest = RinnnaRequestObj(str, PASSWORD)
rinnna.sendToRinnna(rinnnaRequest).enqueue(object : Callback<RinnnaClass> {
override fun onResponse(call: Call<RinnnaClass>, response: Response<RinnnaClass>) {
if (response.isSuccessful()) {
response.body() // have your all data
Log.i("INFO_TAG", response.body()?.getResult())
var result = mutableMapOf<String, String>(
"flag" to (response.body()?.getFlag().toString()),
"result" to (response.body()?.getResult() ?: ""),
"key" to (response.body()?.getKey() ?: "")
)
continueExe(result)
} else {
continueExe(null)
}
}
override fun onFailure(call: Call<RinnnaClass>, t: Throwable) {
continueExe(null)
}
})
}
で、これが自分のサーバーに音声認識の結果を送信する実体部分です。
同期通信がしたかったので、ほんとはenqueueではなくexecuteメソッドとかいうのを使うらしいんですが
なんか動かなかったので諦めました←
書き方の問題だと思うんですが、別に本質じゃないというか、ぶっちゃけこのアプリに関してはとりあえず動けばいいと思ってるので非同期通信で実装しました。
変なif文がコメントアウトで残ってますが、まぁ苦戦した時の名残なので残しておきましょうか(笑)
外部からアクセスされたくないので、パスワードも入れてますが、こんな感じでオブジェクトを作って送信するわけですね。
見て分かると思うのですが、RinnnaRequestObjとかRinnnaInterfaceとかは自分で実装したクラスとインターフェースですが、Retrofitはこれを作らなきゃいけないとこがちょっとめんどくさいとこですね。
まぁでも作ってみたらそんなに難しいものじゃなさそうでした。以下に実際に動くコードのせますね。
public class RinnnaClass {
private boolean flag;
private String result;
private String key;
public Boolean getFlag() {
return flag;
}
public String getResult() {
return result;
}
public String getKey() {
return key;
}
}
interface RinnnaInterface {
// @Headers("Accept: application/json", "Content-type: application/json")
@POST("ファイル名.php")
fun sendToRinnna(@Body rinnna: RinnnaRequestObj): Call<RinnnaClass>
}
public class RinnnaRequestObj {
private String string;
private String password;
public RinnnaRequestObj(String string,String password){
this.string=string;
this.password=password;
}
public String getString() {
return string;
}
public void setString(String string) {
this.string = string;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
ビビってkotlinファイルじゃなくてjavaファイルで作ってたりするんですが、実体としてはこんな感じです。
ゲッターとセッターを両方定義しなきゃいけないのかどうかはわかんないのですが、気になる方は試してみてください。僕はめんどいのでやりません(笑)
最初のRinnnaClass.javaが、自分で作ったAPIから送られてくるデータの形式を表しています。僕のAPIはJSONオブジェクト形式でデータを返却してるんですが、特に何も意識しなくてもいい感じにパーシングしてくれるみたいです。GSONとか使ってるっぽいんですけど・・・・・・。
まぁこんな感じ書けば任意のアドレスにオブジェクトをPOSTできますよーということで。
次に、サーバー側のAPIの実装ですね。twitterのDMにりんなちゃんにメッセージを送り、その返答を受け取る感じです。
public function send(){
//パスワードは正しい。
try {
// クレデンシャル生成
$to = new TwistOAuth(CK, CS, USER_AT, USER_ASE);
// ツイート
$status = $to->post('direct_messages/new', [
"text" => $this->str,
"user_id"=> RINNA_USER_ID
]);
$message_id=$status->id;
$count=0;
usleep(800000);//0.8秒待つ
loop_point:
$response = $to->get('direct_messages', [
"since_id" => $message_id
]);
//var_dump($response);
if(++$count<3 && empty($response)){
//echo "empty ok.";
usleep(200000);//さらに0.2秒遅延する
goto loop_point;
}
if(empty($response)){
//再取得してもりんなは応答していない
return false;
}
$str="";
foreach((array)$response as $r){
if($r->sender->id!=RINNA_USER_ID){
//りんなじゃないユーザーなので消えてもらう
continue;
}
$str.=$r->text;//受け取ったテキストを追記
}
} catch (TwistException $e) {
// エラーを表示
echo "[{$e->getCode()}] {$e->getMessage()}";
new EroorLog($e->getMessage());
die();
}
return $str;
}
/*中略*/
$inp = json_decode(file_get_contents('php://input')); //$input now contains the jsonobj
if(db_check($inp->string)){
$result=(new main($inp->string,$inp->password))->exe();
//[bool メッセージ取得成功可否、具体的に受け取ったメッセージ]をjsonで送信
$json=json_encode((new result($result!=false, $result))->result());
}else{
$result=false;
$json= json_encode((new result(false, "", "DUPLICATION"))->result());
}
echo $json;
まぁこんな感じです。APIキーの取得もそういや結構ハマった気がしますが、いちいちここで解説する必要ないかな(笑)
さっき言った通り、Volleyで重複送信される件でごにょごにょやってた名残でif文とかあるんですけど、実際要らないとは思ってます。まぁ念の為残してるって感じですね。
twitterAPIはプッシュ通知とかしてくれないので、ある程度のアタリをつけてDMを読み込まなくてはいけません。りんなちゃんの応答スピードが結構まばらだったりするので、何回か再取得していますが、こういうことをしてるとAPI制限に引っかかりやすくなるので、実害が出てきたら方針を変えようかなって感じです。
ということでなんか勢いで完成させてしまったので、明日に実際に動かしつつの動画を撮ってみたいなと思います。