THETAプラグインで音声認識モジュールと連携する

## はじめに
リコーの @KA-2 です。

弊社では[RICOH THETA](https://theta360.com/ja/)という全周囲360度撮れるカメラを出しています。
<B>RICOH THETA V</B>や<B>RICOH THETA Z1</B>は、OSに<B>Android</B>を採用しています。Androidアプリを作る感覚でTHETAをカスタマイズすることもでき、そのカスタマイズ機能を「プラグイン」と呼んでいます（詳細は本記事の末尾を参照）。

今回は、<B>ユーザーが話した言葉に応じて、THETAが撮影をしたり設定変更をする</B>というプラグインを作ります。

普通に考えると、、、
「THETAの内蔵マイク→音声認識ライブラリ→認識結果から撮影などを行う」
ということを連想すると思います。

そして、そのような振る舞いをするTHETAプラグインを過去に2例ほど見せていただいています。
2例の違いは「音声認識ライブラリ」です。英語特化のもの、日本語認識が得意なもの、の2例でした。どちらも機械学習の結果を利用し本体完結で音声認識を行うライブラリでした。他にもネットワークを介して音声データを送り、音声認識結果を受け取るような手法も考えられます。機械学習を使わない音声認識も過去からあります。手段としては色々なものが考えられるのですが、、、

現時点では、これらのライブラリを自由に利用できる範囲が、個人利用や研究目的に留まるケースが多いです。ライブラリを包括した成果物をリリースするとなると、有償となったり、実験段階のライブラリのためまだ公開不可であったり、、、と、お手軽さに欠けるのが現状です。
（とはいえ動作はしますので、機械学習や音声認識を研究されている方などに、どんどんTHETAプラグインを作成していただきたいです。）

そこで、「音声認識モジュール」と呼ばれている、マイクや認識アルゴリズム入りのICで構成される<B>小さなハードウェアと連携して、音声認識で全天球撮影をする世界をお手軽に体験してしまおう。</B>というのが、今回の記事の内容となります。

THETA側の振る舞いについては、[こちらの記事](https://qiita.com/KA-2/items/e847c780cc0fee187f89)でいろいろと準備も整いました。
THETAがおしゃべりもしてくれましたが、さらに<B>「自分が話しかけたら、THETAが答えてくれる」</B>という世界が待っているわけです。
これでお一人様のわたしも、寂しさを紛らわすことができるかもしれません。

## 必要なハードウェア

![機材説明.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/265455/911dfb43-f546-d28d-28fa-67b28a46e8b3.jpeg)


### 発話認識モジュール(seeed社)
今回の記事のカナメとなる部材、[seeed社のGrove - Speech Recognizer](http://wiki.seeedstudio.com/Grove-Speech_Recognizer/)です。
リンクをクリックすると詳細な説明ページへ飛べます。

「Hi Cell」というトリガー音声を認識すると、数秒間、命令となる言葉が受付可能な状態となります。
その期間内に、モジュールに予め仕込まれている22の言葉のいずれかを認識すると、シリアル通信経由で言葉に対応する数値が送られます。バイナリのデータです。
言葉と数値の対応関係は[こちら](http://wiki.seeedstudio.com/Grove-Speech_Recognizer/#command-return)を参照してください。

[スイッチサイエンスさん](https://www.switch-science.com/catalog/3313/)から、2019年4月初旬時点、2527円ほどで購入することができます。（他の購入方法もいくらかあると思います。お好みでどうぞ。）

### USB-シリアル変換器
多様なものがありますが、今回は手持ちの[スイッチサイエンス社 FTDI USB Serial Conversion Adapter](https://www.switch-science.com/catalog/1032/)を利用しました。
[GPS/GNSSレシーバーの記事](https://qiita.com/KA-2/items/711c76fa86e16a5e83c3)で紹介したTHETA側に組み込むライブラリも当然対応しています。ライブラリに何も手を加えなくて大丈夫です。

この変換器は 3.3vと5vの二種類の出力電圧を選べます。
発話認識モジュールの仕様にあわせ、電圧のジャンパーピンを5vにしてください。

### GROVE - 4ピン-ジャンパメスケーブル
こちらも[スイッチサイエンスさんから購入](https://www.switch-science.com/catalog/1048/)できます。
半田付けなしで 発話認識モジュールとUSB-シリアル変換機をつなぎたかったため利用しました。以下のように結線しています。

![結線.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/265455/7a33d5e9-8e50-f851-bffe-7425afee9578.png)

|発話認識モジュール|ケーブルの色|USB-シリアル変換器|
|---|---|---|
|Tx|黄|Rx|
|Rx|白|Tx|
|VCC|赤|VCC|
|GND|黒|GND|

発話認識モジュール側はGROVEコネクタという規格ものですので迷うことはないでしょう。
　

### USB OTG adapter
今回は、[GPS/GNSSレシーバーの記事](https://qiita.com/KA-2/items/711c76fa86e16a5e83c3#%E5%BF%85%E8%A6%81%E3%81%AA%E3%83%8F%E3%83%BC%E3%83%89%E3%82%A6%E3%82%A7%E3%82%A2)と同じくケーブルタイプを利用しました。
[HID機器連携(以降「HID Remote」と略します)の記事で紹介したようなアダプタ](https://qiita.com/KA-2/items/e847c780cc0fee187f89#hid%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6%E3%81%A1%E3%82%87%E3%81%A3%E3%81%A8%E8%A3%9C%E8%B6%B3%E4%BB%8A%E5%9B%9E%E3%81%AE%E9%81%A9%E7%94%A8%E7%AF%84%E5%9B%B2)でもOKです。お好みでどうぞ。

### USB Type-A to microB ケーブル
THETAを購入した際に添付されているものです。
両側のコネクタが同じ形式でデータ通信できるものであればどれでもOKです。

## THETAプラグイン側の仕様

「音声認識モジュールがどのような音声認識結果をしたか分かるようにする」「音声コマンドに対応するアクションを自由に選べるようにする」という２点の目的から、以下のようなwebUIを作成しました。

![webUI説明.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/265455/bfe477d7-435a-f129-51ae-21934371db4f.jpeg)


音声コマンドに紐付けできるアクションは[こちらの記事](https://qiita.com/KA-2/items/e847c780cc0fee187f89#%E3%82%AD%E3%83%BC%E3%81%AB%E5%89%B2%E3%82%8A%E5%BD%93%E3%81%A6%E5%8F%AF%E8%83%BD%E3%81%A8%E3%81%97%E3%81%9Ftheta%E5%81%B4%E3%81%AE%E6%8C%AF%E3%82%8B%E8%88%9E%E3%81%84)と同じです。

上記の記事を見て頂けるとわかるのですが、THETAが発話をするアクションもあります。
発話は音声データを再生しており、そのライセンス表記もwebUI末尾にしてあります。

![webUI_licensing_720.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/265455/1f65fc57-80e4-ec8c-49f8-0f8db5028f6c.png)


本体ボタン操作に対応するアクションは、今回達成したい目的とまったく関係がないので、ぼぼ製品動作をトレースしてみました。

|ボタンと操作|アクション|
|---|---|
|シャッターボタン(短押し)|静止画撮影 or 動画開始/停止|
|シャッターボタン(長押し)|無処理|
|無線LANボタン(短押し*1)| オフ->APモード->CLモード 循環 |
|無線LANボタン(長押し)|無処理|
|Modeボタン(短押し*1)|静止画 <-> 動画 モード切り替え *2|
|Modeボタン(長押し)|プラグイン終了に割り当て済み|

*1:短押し操作であってもボタンを離した時(onKeyUp)に反応します。
*2:プラグインからはLiveストリーミングモードにしません。

「短押し」という簡単なボタン操作認識１つとっても「操作に対する反応を速くしたい箇所はonKeyDownを使う」「長押し操作と共存する可能性があるボタンは onKeyUpを使う」という使い分けされているようです。組み込み系ではないソフトウェアエンジニアの方は、ちょっとした気づきがあるのではないでしょうか？

## ソースコード
今回は、[HID Remoteのプロジェクトファイル一式](https://github.com/theta-skunkworks/theta-plugin-usb-hid-sample)をベースとして、[GPS/GNSSレシーバーの記事](https://qiita.com/KA-2/items/711c76fa86e16a5e83c3#android%E7%94%A8%E3%82%B7%E3%83%AA%E3%82%A2%E3%83%AB%E9%80%9A%E4%BF%A1%E3%83%A9%E3%82%A4%E3%83%96%E3%83%A9%E3%83%AA%E5%B0%8E%E5%85%A5%E6%96%B9%E6%B3%95)を参考にシリアル通信にまつわるビルド環境設定（ライブラリの取り込み）を行い、「MainActivity.java」のみを書き換えるという作業をしました。

折りたたんだ形でMainActivity.java全コードを掲載したあと、ポイントになる部分の説明を記載するに留め、発話認識モジュールと連携するプラグインのプロジェクトファイル一式は公開といたしません。それでもトレースは簡単だと思います。

<details><summary>MainActivity.java全コード</summary><div>

```java:MainActivity.java
/**
 * Copyright 2018 Ricoh Company, Ltd.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.theta360.pluginapplication;

import android.content.Context;
import fi.iki.elonen.NanoHTTPD;
import fi.iki.elonen.NanoHTTPD.Response.Status;

import java.util.Map;
import java.util.HashMap;
import java.util.List;
import java.util.Iterator;
import java.io.InputStream;
import java.io.IOException;
import java.io.FileOutputStream;
import java.io.FileInputStream;
import java.io.BufferedReader;
import java.io.InputStreamReader;

import android.content.Intent;
import android.hardware.usb.UsbDeviceConnection;
import android.os.Bundle;
import android.view.KeyEvent;
import android.util.Log;

import com.theta360.pluginapplication.task.CheckApListTask;
import com.theta360.pluginapplication.task.SoundManagerTask;
import com.theta360.pluginapplication.task.ShutterButtonTask;
import com.theta360.pluginapplication.task.ChangeCaptureModeTask;
import com.theta360.pluginapplication.task.ChangeVolumeTask;
import com.theta360.pluginapplication.task.ChangeExposureDelayTask;
import com.theta360.pluginapplication.task.ChangeEvTask;

import com.theta360.pluginlibrary.activity.PluginActivity;
import com.theta360.pluginlibrary.callback.KeyCallback;
import com.theta360.pluginlibrary.receiver.KeyReceiver;

//シリアル通信まわりで使用
import android.app.PendingIntent;
import android.hardware.usb.UsbManager;
import com.hoho.android.usbserial.driver.UsbSerialDriver;
import com.hoho.android.usbserial.driver.UsbSerialPort;
import com.hoho.android.usbserial.driver.UsbSerialProber;
import com.theta360.pluginlibrary.values.LedColor;
import com.theta360.pluginlibrary.values.LedTarget;



public class MainActivity extends PluginActivity {
    private static final String TAG = "SPEECH_RECOGNIZER";

    private static final int WLAN_MODE_OFF = 0;
    private static final int WLAN_MODE_AP = 1;
    private static final int WLAN_MODE_CL = 2;
    private int wlanMode = WLAN_MODE_AP;
    private boolean wlanClList = false;

    private boolean firstModeButtonUp = false;

    private static final int SPEECH_VOLUME_STEP = 20;
    private static final int SPEECH_VOLUME_MAX = 100;
    private static final int SPEECH_VOLUME_MIN = 0;
    private int speechVolume = SPEECH_VOLUME_MIN;
    private int defaultSpeechVolume = SPEECH_VOLUME_MAX;

    //シリアル通信関連
    private boolean mFinished;
    private UsbSerialPort port ;

    //USBデバイスへのパーミッション付与関連
    PendingIntent mPermissionIntent;
    private static final String ACTION_USB_PERMISSION = "com.android.example.USB_PERMISSION";


    private CheckApListTask.Callback mCheckApListCallback = new CheckApListTask.Callback() {
        @Override
        public void onCheckApList(int listNum) {
            if (listNum == 0) {
                wlanClList = false;
            } else {
                wlanClList = true;
            }
        }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // Set enable to close by pluginlibrary, If you set false, please call close() after finishing your end processing.
        setAutoClose(true);
        // Set a callback when a button operation event is acquired.
        setKeyCallback(new KeyCallback() {
            @Override
            public void onKeyDown(int keyCode, KeyEvent event) {
                //通常状態と同じ動作をさせる
                if (keyCode == KeyReceiver.KEYCODE_CAMERA) {
                    execKeyProcess(EXEC_SHUTTER);
                } else {
                    //NOP
                }
            }

            @Override
            public void onKeyUp(int keyCode, KeyEvent event) {
                //通常状態と同じ動作をさせる
                if (keyCode == KeyReceiver.KEYCODE_WLAN_ON_OFF) {
                    execKeyProcess(TGGLE_WLAN);
                } else if (keyCode == KeyReceiver.KEYCODE_MEDIA_RECORD) {
                    if (firstModeButtonUp) {
                        execKeyProcess(TGGLE_CAPTURE_MODE);
                    } else {
                        firstModeButtonUp = true;
                    }
                } else {
                    //NOP
                }
            }

            @Override
            public void onKeyLongPress(int keyCode, KeyEvent event) {
                //NOP
            }

        });

        //webUI用のサーバー開始処理
        this.context = getApplicationContext();
        this.webServer = new WebServer(this.context);
        try {
            this.webServer.start();
        } catch (IOException e) {
            e.printStackTrace();
        }

    }

    @Override
    protected void onResume() {
        super.onResume();

        //前回起動から時引き継ぐべき状態を読み込む
        readPluginSetting();
        readInitalList();

        // LEDを正しく点灯させるため、わざと動画モードにしてから静止画モードにする。
        new ChangeCaptureModeTask("video").execute();
        new ChangeCaptureModeTask("image").execute();

        //WlanをAPモードから開始する
        notificationWlanAp();   // プラグイン起動前からAPモードでVysorのWLANデバッグしていても影響なし
        wlanMode = WLAN_MODE_AP;

        //Wlan CLモード用のリスト有無チェック -> リストなしは Off<->AP 、ありは Off->AP->CL->Off->...
        new CheckApListTask(mCheckApListCallback).execute();

        //---------------  added code ---------------
        mFinished = true;

        // Find all available drivers from attached devices.
        UsbManager manager = (UsbManager) getSystemService(Context.USB_SERVICE);
        List<UsbSerialDriver> usb = UsbSerialProber.getDefaultProber().findAllDrivers(manager);
        //定義済みのFTDIを利用するため、新たな定義を追加する必要がない
        /*
        final ProbeTable probeTable = UsbSerialProber.getDefaultProbeTable();
        probeTable.addProduct(0x1546,0x01a7,CdcAcmSerialDriver.class);
        List<UsbSerialDriver> usb = new UsbSerialProber(probeTable).findAllDrivers(manager);
        */

        if (usb.isEmpty()) {
            int usb_num = usb.size();
            Log.d(TAG,"usb num =" + usb_num  );
            Log.d(TAG,"usb device is not connect."  );
            //return;
            //port = null;
        } else {
            // デバッグのため認識したデバイス数をしらべておく
            int usb_num = usb.size();
            Log.d(TAG,"usb num =" + usb_num  );

            // Open a connection to the first available driver.
            UsbSerialDriver driver = usb.get(0);

            //USBデバイスへのパーミッション付与用（機器を刺したときスルーしてもアプリ起動時にチャンスを与えるだけ。なくても良い。）
            mPermissionIntent = PendingIntent.getBroadcast(this, 0, new Intent(ACTION_USB_PERMISSION), 0);
            manager.requestPermission( driver.getDevice() , mPermissionIntent);

            UsbDeviceConnection connection = manager.openDevice(driver.getDevice());
            if (connection == null) {
                // You probably need to call UsbManager.requestPermission(driver.getDevice(), ..)
                // パーミッションを与えた後でも、USB機器が接続されたままの電源Off->On だとnullになる... 刺しなおせばOK
                Log.d(TAG,"M:Can't open usb device.\n");

                port = null;
            } else {
                port = driver.getPorts().get(0);

                try {
                    port.open(connection);
                    //port.setParameters(115200, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE);
                    port.setParameters(9600, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE);

                    mFinished = false;
                    start_read_thread();

                } catch (IOException e) {
                    // Deal with error.
                    e.printStackTrace();
                    Log.d(TAG, "M:IOException");
                    //return;
                } finally {
                    Log.d(TAG, "M:finally");
                }
            }
        }
        //-----------------------------------------
    }

    @Override
    protected void onPause() {
        // Do end processing

        //次回起動時引き継ぐべき状態を保存
        savePluginSetting();

        //---------------  added code ---------------
        //スレッドを終わらせる指示。終了待ちしていません。
        mFinished = true;

        //シリアル通信の後片付け ポート開けてない場合にはCloseしないこと
        if (port != null) {
            try {
                port.close();
                Log.d(TAG, "M:onDestroy() port.close()");
            } catch (IOException e) {
                Log.d(TAG, "M:onDestroy() IOException");
            }
        } else {
            Log.d(TAG, "M:port=null\n");
        }
        //-----------------------------------------

        super.onPause();
    }

    //=====================================================
    //<<< Serial thread  >>>
    //=====================================================
    private int lastSpeechCommand = -1;

    String SpeechCommand[] = {
            "- Zero is Undefined -",
            "Turn on the light",
            "Turn off the light",
            "Play music",
            "Pause",
            "Next",
            "Previous",
            "Up",
            "Down",
            "Turn on the TV",
            "Turn off the TV",
            "Increase temperature",
            "Decrease temperature",
            "What’s the time",
            "Open the door",
            "Close the door",
            "Left",
            "Right",
            "Stop",
            "Start",
            "Mode 1",
            "Mode 2",
            "Go",
    };
    private int listFactorySettings[] = {
            NO_PROCESS,		    // - Zero is Undefined -
            NO_PROCESS,		    //  1:"Turn on the light"
            NO_PROCESS,		    //  2:"Turn off the light"
            NO_PROCESS,		    //  3:"Play music"
            SET_EV_ZERO,		    //  4:"Pause"
            TGGLE_WLAN,		    //  5:"Next"
            TGGLE_CAPTURE_MODE,	//  6:"Previous"
            SET_EV_PLUS,	        //  7:"Up"
            SET_EV_MINUS,	        //  8:"Down"
            NO_PROCESS,		    //  9:"Turn on the TV"
            NO_PROCESS,		    // 10:"Turn off the TV"
            SET_CAMERA_VOL_PLUS,	// 11:"Increase temperature"
            SET_CAMERA_VOL_MINUS,	// 12:"Decrease temperature"
            NO_PROCESS,		    // 13:"What’s the time"
            NO_PROCESS,		    // 14:"Open the door"
            NO_PROCESS,		    // 15:"Close the door"
            SET_SPEECH_VOL_MINUS,	// 16:"Left"
            SET_SPEECH_VOL_PLUS,	// 17:"Right"
            SET_EXP_DELAY_OFF,	// 18:"Stop"
            SET_EXP_DELAY_5,		// 19:"Start"
            SET_CAP_MODE_IMAGE,	// 20:"Mode 1"
            SET_CAP_MODE_VIDEO,	// 21:"Mode 2"
            EXEC_SHUTTER,		    // 22:"Go"
    };
    private int listCommandNum2Process[] = {
            NO_PROCESS,		    // - Zero is Undefined -
            NO_PROCESS,		    //  1:"Turn on the light"
            NO_PROCESS,		    //  2:"Turn off the light"
            NO_PROCESS,		    //  3:"Play music"
            NO_PROCESS,		    //  4:"Pause"
            NO_PROCESS,		    //  5:"Next"
            NO_PROCESS,	        //  6:"Previous"
            NO_PROCESS,	        //  7:"Up"
            NO_PROCESS,	        //  8:"Down"
            NO_PROCESS,		    //  9:"Turn on the TV"
            NO_PROCESS,		    // 10:"Turn off the TV"
            NO_PROCESS,	        // 11:"Increase temperature"
            NO_PROCESS,	        // 12:"Decrease temperature"
            NO_PROCESS,		    // 13:"What’s the time"
            NO_PROCESS,		    // 14:"Open the door"
            NO_PROCESS,		    // 15:"Close the door"
            NO_PROCESS,	        // 16:"Left"
            NO_PROCESS,	        // 17:"Right"
            NO_PROCESS,	        // 18:"Stop"
            NO_PROCESS,		    // 19:"Start"
            NO_PROCESS,	        // 20:"Mode 1"
            NO_PROCESS,	        // 21:"Mode 2"
            NO_PROCESS,		    // 22:"Go"
    };


    //シリアル受信スレッド
    public void start_read_thread(){
        new Thread(new Runnable(){
            @Override
            public void run() {

                try {
                    //notificationLedBlink(LedTarget.LED3, LedColor.MAGENTA, 500);
                    Log.d(TAG, "Thread Start");

                    while(mFinished==false){

                        //シリアル通信 受信ポーリング部
                        byte buff[] = new byte[256];
                        int num = port.read(buff, buff.length);
                        if ( num == 1 ) {
                            int index = buff[num-1];
                            Log.d(TAG, "RcvDat=[" +index + "] (" + SpeechCommand[index] + ") -> " + processName[listCommandNum2Process[index]]);
                            execKeyProcess(listCommandNum2Process[index]);
                            lastSpeechCommand = index;
                        }

                        //ポーリングが高頻度になりすぎないよう10msスリープする
                        Thread.sleep(10);
                    }

                } catch (IOException e) {
                    // Deal with error.
                    e.printStackTrace();
                    Log.d(TAG, "T:IOException");
                } catch (InterruptedException e) {
                    // Deal with error.
                    e.printStackTrace();
                    Log.d(TAG, "T:InterruptedException");
                } finally {
                    Log.d(TAG, "T:finally");
                }
            }
        }).start();
    }
    //-----------------------------------------


    //=====================================================
    //<<< KeyProcess >>>
    //=====================================================

    String processName[] = {
            "NOP",
            "EXEC_SHUTTER",
            "TGGLE_WLAN",
            "TGGLE_CAPTURE_MODE",
            "TGGLE_EXP_DELAY",
            "TGGLE_CAMERA_VOL",
            "TGGLE_SPEECH_VOL",
            "SET_EV_ZERO",
            "SET_EV_PLUS",
            "SET_EV_MINUS",
            "SET_EXP_DELAY_OFF",
            "SET_EXP_DELAY_1",
            "SET_EXP_DELAY_2",
            "SET_EXP_DELAY_3",
            "SET_EXP_DELAY_4",
            "SET_EXP_DELAY_5",
            "SET_EXP_DELAY_6",
            "SET_EXP_DELAY_7",
            "SET_EXP_DELAY_8",
            "SET_EXP_DELAY_9",
            "SET_EXP_DELAY_10",
            "SET_CAP_MODE_IMAGE",
            "SET_CAP_MODE_VIDEO",
            "SET_CAMERA_VOL_PLUS",
            "SET_CAMERA_VOL_MINUS",
            "SET_SPEECH_VOL_PLUS",
            "SET_SPEECH_VOL_MINUS",
    };
    public static final int WITH_INPUT = -1;  //use key operation history
    public static final int NO_PROCESS = 0;
    public static final int EXEC_SHUTTER = 1;
    public static final int TGGLE_WLAN = 2;
    public static final int TGGLE_CAPTURE_MODE = 3;
    public static final int TGGLE_EXP_DELAY = 4;
    public static final int TGGLE_CAMERA_VOL = 5;
    public static final int TGGLE_SPEECH_VOL = 6;
    public static final int SET_EV_ZERO = 7;
    public static final int SET_EV_PLUS = 8;
    public static final int SET_EV_MINUS = 9;
    public static final int SET_EXP_DELAY_OFF = 10;
    public static final int SET_EXP_DELAY_1 = 11;
    public static final int SET_EXP_DELAY_2 = 12;
    public static final int SET_EXP_DELAY_3 = 13;
    public static final int SET_EXP_DELAY_4 = 14;
    public static final int SET_EXP_DELAY_5 = 15;
    public static final int SET_EXP_DELAY_6 = 16;
    public static final int SET_EXP_DELAY_7 = 17;
    public static final int SET_EXP_DELAY_8 = 18;
    public static final int SET_EXP_DELAY_9 = 19;
    public static final int SET_EXP_DELAY_10 = 20;
    public static final int SET_CAP_MODE_IMAGE = 21;
    public static final int SET_CAP_MODE_VIDEO = 22;
    public static final int SET_CAMERA_VOL_PLUS = 23;
    public static final int SET_CAMERA_VOL_MINUS = 24;
    public static final int SET_SPEECH_VOL_PLUS = 25;
    public static final int SET_SPEECH_VOL_MINUS = 26;
    public static final int PROCESS_CODE_MAX_NUM = 27;//Last Code + 1
    public static final int PROCESS_CODE_SIMPLE_NUM = SET_EXP_DELAY_OFF + 1;

    private void execKeyProcess(int processCode) {

        switch (processCode) {
            case EXEC_SHUTTER:
                new ShutterButtonTask().execute();
                break;
            case TGGLE_WLAN:
                changeWlanMode();
                break;
            case TGGLE_CAPTURE_MODE:
                new ChangeCaptureModeTask(ChangeCaptureModeTask.CAPMODE_TGGLE).execute();
                break;
            case TGGLE_EXP_DELAY:
                new ChangeExposureDelayTask(getApplicationContext(), ChangeExposureDelayTask.EXPOSURE_DELAY_TGGLE, speechVolume).execute();
                break;
            case TGGLE_CAMERA_VOL:
                new ChangeVolumeTask(getApplicationContext(), ChangeVolumeTask.VOLUME_TGGLE).execute();
                break;
            case TGGLE_SPEECH_VOL:
                changeSpeechVolume();
                break;
            case SET_EV_ZERO:
                new ChangeEvTask(getApplicationContext(), ChangeEvTask.EV_ZERO, speechVolume).execute();
                break;
            case SET_EV_PLUS:
                new ChangeEvTask(getApplicationContext(), ChangeEvTask.EV_PLUS, speechVolume).execute();
                break;
            case SET_EV_MINUS:
                new ChangeEvTask(getApplicationContext(), ChangeEvTask.EV_MINUS, speechVolume).execute();
                break;
            case SET_EXP_DELAY_OFF:
                new ChangeExposureDelayTask(getApplicationContext(), ChangeExposureDelayTask.EXPOSURE_DELAY_OFF, speechVolume).execute();
                break;
            case SET_EXP_DELAY_1:
            case SET_EXP_DELAY_2:
            case SET_EXP_DELAY_3:
            case SET_EXP_DELAY_4:
            case SET_EXP_DELAY_5:
            case SET_EXP_DELAY_6:
            case SET_EXP_DELAY_7:
            case SET_EXP_DELAY_8:
            case SET_EXP_DELAY_9:
            case SET_EXP_DELAY_10:
                new ChangeExposureDelayTask(getApplicationContext(), (processCode - SET_EXP_DELAY_OFF), speechVolume).execute();
                break;
            case SET_CAP_MODE_IMAGE:
                new ChangeCaptureModeTask(ChangeCaptureModeTask.CAPMODE_IMAGE).execute();
                break;
            case SET_CAP_MODE_VIDEO:
                new ChangeCaptureModeTask(ChangeCaptureModeTask.CAPMODE_VIDEO).execute();
                break;
            case SET_CAMERA_VOL_PLUS:
                new ChangeVolumeTask(getApplicationContext(), 10).execute();
                break;
            case SET_CAMERA_VOL_MINUS:
                new ChangeVolumeTask(getApplicationContext(), -10).execute();
                break;
            case SET_SPEECH_VOL_PLUS:
                speechVolume += SPEECH_VOLUME_STEP;
                if (speechVolume > SPEECH_VOLUME_MAX) {
                    speechVolume = SPEECH_VOLUME_MAX;
                }
                new SoundManagerTask(getApplicationContext(), R.raw.speech_volume, speechVolume).execute();
                break;
            case SET_SPEECH_VOL_MINUS:
                speechVolume -= SPEECH_VOLUME_STEP;
                if (speechVolume < SPEECH_VOLUME_MIN) {
                    speechVolume = SPEECH_VOLUME_MIN;
                }
                new SoundManagerTask(getApplicationContext(), R.raw.speech_volume, speechVolume).execute();
                break;
        }

        return;
    }

    //=====================================================
    //<<< Plugin Settings >>>
    //=====================================================
    private void setFactorySettings() {
        //発話音量
        speechVolume = defaultSpeechVolume;

        //コマンドto実行テーブルを出荷設定にする
        for (int i=0; i<listCommandNum2Process.length; i++) {
            listCommandNum2Process[i] = listFactorySettings[i] ;
        }
    }
    private void clearSettings() {
        //発話音量
        speechVolume = defaultSpeechVolume;

        //コマンドto実行テーブルクリア
        for (int i=0; i<listCommandNum2Process.length; i++) {
            listCommandNum2Process[i] = NO_PROCESS ;
        }
    }

    private static final String pluginSettingFileName = "plugin.txt";
    private void readPluginSetting() {
        InputStream in;
        try {
            FileInputStream fileInputStream = openFileInput(pluginSettingFileName);
            BufferedReader buffReader = new BufferedReader( new InputStreamReader(fileInputStream, "UTF-8") );
            //ファイルがある場合
            Log.d(TAG, "open file :" + pluginSettingFileName);

            String line = "";
            while( (line = buffReader.readLine()) != null ){
                //Log.d(TAG, line);

                String[] element = line.split("=");
                Log.d(TAG, "[0]=" + element[0] + ", [1]=" + element[1]);
                if  (element[0].equals("speechVolume")) {
                    if ( this.isNumber(element[1]) ) {
                        speechVolume = Integer.parseInt(element[1]);
                    } else {
                        speechVolume = 100;
                    }
                } else {
                    //無処理
                }

            }
            buffReader.close();

        } catch (IOException e) {
            //e.printStackTrace();

            //ファイルがない場合
            Log.d(TAG, "Can't open file :" + pluginSettingFileName);

            //発話音量
            speechVolume = defaultSpeechVolume;
        }

    }
    private void savePluginSetting() {
        //ファイルに保存する
        FileOutputStream  out;

        try {
            out = openFileOutput(pluginSettingFileName, MODE_PRIVATE);

            //発話音量
            String outStrSV = "speechVolume=" + String.valueOf(speechVolume) + "\n";
            byte[] buffSV = outStrSV.getBytes();
            out.write(buffSV,0, outStrSV.length());

            out.close();
        } catch (IOException e) {
            e.printStackTrace();
        }

    }

    public static boolean isNumber(String s) {
        try {
            Integer.parseInt(s);
            return true;
        } catch (NumberFormatException e) {
            return false;
        }
    }
    private static final String initialFileName = "initKey.txt";
    private void readInitalList() {

        try {
            FileInputStream fileInputStream = openFileInput(initialFileName);
            BufferedReader buffReader = new BufferedReader(new InputStreamReader(fileInputStream, "UTF-8"));
            //ファイルがある場合
            Log.d(TAG, "open file :" + initialFileName);

            String line = "";
            while( (line = buffReader.readLine()) != null ){
                String[] element = line.split("=");
                Log.d(TAG, "[0]=" + element[0] + ", [1]=" + element[1]);
                if ( this.isNumber(element[0]) ) {
                    if ( this.isNumber(element[1]) ) {
                        int speechCommand = Integer.parseInt(element[0]);
                        int processCode = Integer.parseInt(element[1]);
                        listCommandNum2Process[speechCommand] = processCode;
                    }
                } else {
                    //無処理
                }
            }
            buffReader.close();
        } catch (IOException e) {
            //e.printStackTrace();

            //ファイルがない場合
            Log.d(TAG, "Can't open file :" + initialFileName);
            setFactorySettings();
        }

    }
    private void saveInitalList() {
        //ファイルに保存する
        FileOutputStream  out;

        try {
            out = openFileOutput(initialFileName, MODE_PRIVATE);

            for (int i = 0; i < listCommandNum2Process.length; i++) {
                int processCode = listCommandNum2Process[i];
                if ( (0 <= processCode) && (processCode < PROCESS_CODE_MAX_NUM) ) {
                    String outKeyCode2Process = String.valueOf(i) + "=" + String.valueOf(processCode) + "\n";
                    byte[] buffKeyCode2Process = outKeyCode2Process.getBytes();
                    out.write(buffKeyCode2Process,0, outKeyCode2Process.length());
                }
            }

           out.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    //=====================================================
    //<<< Non-task processings >>>
    //=====================================================
    private void changeWlanMode() {
        switch (wlanMode) {
            case WLAN_MODE_OFF :
                notificationWlanAp();
                wlanMode = WLAN_MODE_AP;
                break ;
            case WLAN_MODE_AP :
                if (wlanClList == true) {
                    notificationWlanCl();
                    wlanMode = WLAN_MODE_CL;
                } else {
                    notificationWlanOff();
                    wlanMode = WLAN_MODE_OFF;
                }
                break ;
            case WLAN_MODE_CL :
                notificationWlanOff();
                wlanMode = WLAN_MODE_OFF;
                break ;
        }
    }

    private void changeSpeechVolume() {

        if ( 0<=speechVolume && speechVolume< 30 ) {
            speechVolume = 50;    // Off to Mid
        } else if (30<=speechVolume && speechVolume< 80) {
            speechVolume = 100;   // Mid to Max
        } else { // 70 to 100
            speechVolume = 0;     // Max to Off
        }
        new SoundManagerTask(getApplicationContext(), R.raw.speech_volume , speechVolume).execute();
    }


    //=====================================================
    //<<< web server processings >>>
    //=====================================================
    private Context context;
    private WebServer webServer;

    protected void onDestroy() {
        super.onDestroy();
        if (this.webServer != null) {
            this.webServer.stop();
        }
    }
    private class WebServer extends NanoHTTPD {

        private static final int PORT = 8888;
        private Context context;

        public WebServer(Context context) {
            super(PORT);
            this.context = context;
        }

        @Override
        public Response serve(IHTTPSession session) {
            Method method = session.getMethod();
            String uri = session.getUri();

            switch (method) {
                case GET:
                    return this.serveHtml(uri);
                case POST:
                    Map<String, List<String>> parameters = this.parseBodyParameters(session);
                    Log.d(TAG, "parameters=" + parameters.toString() );
                    execButtonAction(parameters);
                    return this.serveHtml(uri);
                default:
                    return newFixedLengthResponse(Status.METHOD_NOT_ALLOWED, "text/plain",
                            "Method [" + method + "] is not allowed.");
            }
        }

        private Map<String, List<String>> parseBodyParameters(IHTTPSession session) {
            Map<String, String> tmpRequestFile = new HashMap<>();
            try {
                session.parseBody(tmpRequestFile);
            } catch (IOException e) {
                e.printStackTrace();
            } catch (ResponseException e) {
                e.printStackTrace();
            }
            return session.getParameters();
        }

        private Response serveHtml(String uri) {
            String html="";

            switch (uri) {
                case "/":
                    html = editHtml();
                    return newFixedLengthResponse(Status.OK, "text/html", html);
                default:
                    html = "URI [" + uri + "] is not found.";
                    return newFixedLengthResponse(Status.NOT_FOUND, "text/plain", html);
            }
        }

        public static final String buttonName1 = "Save" ;
        public static final String buttonName2 = "Reload" ;
        public static final String buttonName3 = "Clear settings" ;
        public static final String buttonName4 = "Factory settings" ;

        private void execButtonAction( Map<String, List<String>> inParameters ) {
            if (inParameters.containsKey("button")) {
                List<String> button = inParameters.get("button");
                Log.d(TAG, "button=" + button.toString() );

                if ( button.get(0).equals(buttonName1) ) {         //Save
                    updateSettings(inParameters);
                    saveInitalList();
                    readInitalList();
                } else if ( button.get(0).equals(buttonName2) ) {  //Reload
                    updateSettings(inParameters);
                } else if ( button.get(0).equals(buttonName3) ) {  //Clear settings
                    clearSettings();
                } else if ( button.get(0).equals(buttonName4) ) {  //Factory settings
                    setFactorySettings();
                }
            }
            return;
        }

        private void updateSettings( Map<String, List<String>> inParameters ) {
            if (inParameters.containsKey("button")) {
                inParameters.remove("button");
            }

            Iterator<Map.Entry<String, List<String>>>  iterator = inParameters.entrySet().iterator();
            while (iterator.hasNext()) {
                Map.Entry<String, List<String>> entryData = iterator.next();
                String entryKeyStr = entryData.getKey();
                List<String> entryVal = entryData.getValue();

                int entryCommandCode = Integer.parseInt(entryKeyStr);
                int entryProcessCode = Integer.parseInt(entryVal.get(0));
                if ( (0<entryCommandCode) && (entryCommandCode<22) ) {
                    if ( (0<=entryProcessCode) && (entryProcessCode<PROCESS_CODE_MAX_NUM) ) {
                        Log.d(TAG, "entryKeyCode[" + String.valueOf(entryCommandCode) + "]: set=" + String.valueOf(entryProcessCode) );
                        listCommandNum2Process[entryCommandCode] = entryProcessCode;
                    }
                }
            }
        }

        private String editHtml() {
            String html="";
            html += "<html>";
            html += "<head>";
            //html += "  <meta name='viewport' content='width=device-width,initial-scale=1'>";
            html += "  <meta name='viewport' content='width=480,initial-scale=0.7'>";
            html += "  <title>Speech Recognizer : Setting</title>";
            html += "  <script type='text/javascript'>";
            html += "  </script>";
            html += "</head>";
            html += "<body>";
            html += "";
            html += "<form action='/' method='post' name='SettingForm'>";
            html += "  <hr>";
            html += "  <h2>[Speech Recognizer : Setting]</h2>";
            html += "  <table>";
            html += "    <tr>";
            html += "      <td><input type='submit' name='button' value='" + buttonName1 + "'></td>";
            html += "      <td> </td>";
            html += "      <td> </td>";
            html += "      <td><input type='submit' name='button' value='" + buttonName2 + "'></td>";
            html += "      <td> </td>";
            html += "      <td> </td>";
            html += "      <td><input type='submit' name='button' value='" + buttonName3 + "'></td>";
            html += "      <td> </td>";
            html += "      <td> </td>";
            html += "      <td><input type='submit' name='button' value='" + buttonName4 + "'></td>";
            html += "      <td> </td>";

            html += "    </tr>";
            html += "  </table>";
            html += "  <hr>";
            html += "  <h3>Speech Command to Action</h3>";
            html += "  ";
            html += "  <table>";

            html += "    <tr>";
            html += "      <td style='background:#CCCCCC'>No</td>";
            html += "      <td style='background:#CCCCCC'>Speeach Command</td>";
            html += "      <td style='background:#CCCCCC'>Linked action</td>";
            html += "    </tr>";

            for ( int i=1; i<listCommandNum2Process.length; i++ ) {
                html += "    <tr>";
                html += "      <td >" + String.valueOf(i) + "</td>";
                html += "      <td " + editCellColor(i) + ">" + SpeechCommand[i] + "</td>";
                html += "      <td>";
                html += editSelectOption(i);
                html += "      </td>";
                html += "    </tr>";
            }

            html += "  </table>";
            html += "  <br>";
            html += "";
            html += "  <hr>";

            html += "  <h3>About voice data licensing</h3>";
            html += "  The sound data used in this program is created at the following site.<br>";
            html += "  <a href='https://note.cman.jp/other/voice/'>https://note.cman.jp/other/voice/</a><br>";
            html += "  <br>";
            html += "  For the sound created at the above site, the following license notation is necessary.<br>";
            html += "  <br>";
            html += "  <a href='https://creativecommons.org/licenses/by/3.0/deed.ja'><img src='http://mirrors.creativecommons.org/presskit/buttons/80x15/png/by.png' alt='Creative Commons Attribution (CC-BY) 3.0 licens'></a><br>";
            html += "  <br>";
            html += "  HTS Voice \"Mei(Normal)\" Copyright (c) 2009-2013 Nagoya Institute of Technology<br>";
            html += "  <br>";
            html += "  For details of license data of audio data, please refer to <a href='http://www.mmdagent.jp/'>http://www.mmdagent.jp/</a>.<br>";

            html += "</form>";
            html += "";
            html += "</body>";
            html += "</html>";

            return html;
        }

        private String editSelectOption(int SpeechCode) {
            String result = "";
            int curProcessCode = listCommandNum2Process[SpeechCode];
            int selectDispNum = PROCESS_CODE_MAX_NUM;

            result += "<select name='" + String.valueOf(SpeechCode) + "'>";
            for (int i=0; i<selectDispNum; i++) {
                result += "<option value='" + String.valueOf(i) + "'";
                if (i==curProcessCode)  {
                    result += " selected";
                }
                result += ">" + processName[i] + "</option>";
            }
            result += "</select>";

            return result ;
        }

        private String editCellColor(int SpeechCode) {
            String result = "";
            if (SpeechCode == lastSpeechCommand) {
                result = " style='background:#FF5555'";
            }
            return result ;
        }

    }

}

```

</div></details>

### 本体ボタン操作部分

```java:MainActivity.java
        setKeyCallback(new KeyCallback() {
            @Override
            public void onKeyDown(int keyCode, KeyEvent event) {
                //通常状態と同じ動作をさせる
                if (keyCode == KeyReceiver.KEYCODE_CAMERA) {
                    execKeyProcess(EXEC_SHUTTER);
                } else {
                    //NOP
                }
            }

            @Override
            public void onKeyUp(int keyCode, KeyEvent event) {
                //通常状態と同じ動作をさせる
                if (keyCode == KeyReceiver.KEYCODE_WLAN_ON_OFF) {
                    execKeyProcess(TGGLE_WLAN);
                } else if (keyCode == KeyReceiver.KEYCODE_MEDIA_RECORD) {
                    if (firstModeButtonUp) {
                        execKeyProcess(TGGLE_CAPTURE_MODE);
                    } else {
                        firstModeButtonUp = true;
                    }
                } else {
                    //NOP
                }
            }

            @Override
            public void onKeyLongPress(int keyCode, KeyEvent event) {
                //NOP
            }

        });

```
プラグイン起動するためにModeボタン長押しがされると、プラグインが起動したあとに指を離すこととなり、そのイベントがプラグインにまで届いてしまいます。
このonKeyUpを「firstModeButtonUp」の変数によってスルーするようにしている点くらいがポイントです。HID Remoteプラグインでも同じことをしていますが、コードが少々ややこしくなっていたので、今回のほうが理解しやすいと思います。

あとは、HID Remoteプラグインを作成するときに用意していたtask群がとても簡単に流用できることが理解できるかと思います。わりと便利でしょ？


### シリアル通信部分
「onResume()」と「onPause()」に追加したシリアル通信のためのコードは[GPS/GNSSレシーバーの記事](https://qiita.com/KA-2/items/711c76fa86e16a5e83c3#mainactivityjava)を読んでいただけるとわかると思いますので割愛します。

今回のシリアル通信機器は、利用しているライブラリに「ベンダーID、プロダクトID」が予め登録されているのでコードが以下のように簡単になります。
あえてコメントアウト部分も残していますので理解がすすむと思われます。

```java:MainActivity.java
        // Find all available drivers from attached devices.
        UsbManager manager = (UsbManager) getSystemService(Context.USB_SERVICE);
        List<UsbSerialDriver> usb = UsbSerialProber.getDefaultProber().findAllDrivers(manager);
        //定義済みのFTDIを利用するため、新たな定義を追加する必要がない
        /*
        final ProbeTable probeTable = UsbSerialProber.getDefaultProbeTable();
        probeTable.addProduct(0x1546,0x01a7,CdcAcmSerialDriver.class);
        List<UsbSerialDriver> usb = new UsbSerialProber(probeTable).findAllDrivers(manager);
        */
```

シリアル通信で、発話認識モジュールが解釈できた言葉の番号を受け取る部分は以下です。

```java:MainActivity.java
    //シリアル受信スレッド
    public void start_read_thread(){
        new Thread(new Runnable(){
            @Override
            public void run() {

                try {
                    //notificationLedBlink(LedTarget.LED3, LedColor.MAGENTA, 500);
                    Log.d(TAG, "Thread Start");

                    while(mFinished==false){

                        //シリアル通信 受信ポーリング部
                        byte buff[] = new byte[256];
                        int num = port.read(buff, buff.length);
                        if ( num == 1 ) {
                            int index = buff[num-1];
                            Log.d(TAG, "RcvDat=[" +index + "] (" + SpeechCommand[index] + ") -> " + processName[listCommandNum2Process[index]]);
                            execKeyProcess(listCommandNum2Process[index]);
                            lastSpeechCommand = index;
                        }

                        //ポーリングが高頻度になりすぎないよう10msスリープする
                        Thread.sleep(10);
                    }

                } catch (IOException e) {
                    // Deal with error.
                    e.printStackTrace();
                    Log.d(TAG, "T:IOException");
                } catch (InterruptedException e) {
                    // Deal with error.
                    e.printStackTrace();
                    Log.d(TAG, "T:InterruptedException");
                } finally {
                    Log.d(TAG, "T:finally");
                }
            }
        }).start();
    }
    //-----------------------------------------
```

「listCommandNum2Process[]」という配列を使って、受信したコマンドの番号(=index) から、行うべきアクションの番号へ数値の読み替えをしています。


### webUI部分など。

前述の「listCommandNum2Process[]」という配列の値を書き換えると、発話に対応するアクションを変えられるということがわかると思います。

ですので、この配列をあれやこれやするための関数群があったり、プラグイン終了時にはその設定を覚えさせ、プラグイン起動時には覚えている設定を読み込み、などなどな処理をする関数群がいくらかあります。

後半には、「listCommandNum2Process[]」という配列の状態を表示したり変更したりするwebUIがあるだけになり、その大枠は [HID Remoteの記事](https://qiita.com/KA-2/items/e847c780cc0fee187f89#%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%A0%E3%81%AE%E8%AA%AC%E6%98%8E)と同じです。コード量としては減っていますし、細かい説明は割愛します。


## 動作させてみた
youtubeに動画をアップしています。2分20秒くらいの動画です。
画像をクリックすると[youtube](https://youtu.be/SEMn07FA7NQ)へ飛びます。
twitter用の短編動画より細かな点がわかりやすいと思いますので、ご参照頂ければと思います。

[![タイトル画像.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/265455/d0999b7c-258d-38df-0db5-98e5f19b217d.jpeg)](https://youtu.be/SEMn07FA7NQ)


## まとめ

「はじめに」の章で言い訳を書いたとおり「小物がくっついた状態」ではあるものの、だいたい狙いとおり、しゃべるとTHETAが応答するという世界が垣間見えました。

おおむね成功ですっ☆（どこかの気ぐるみキャラクタで再生してください）

今回使用した「モジュールの個性」から、ベッタベタの日本人である私を含む複数名の社員では「Hi Cell」というトリガーすら認識してもらえないということが分かりました。英語の発音が上手な方は使いこなせるようなのですが･･･
THETA本体にマイクがあるのに、外付けハードウェアで音声認識という状況も勿体ないので、

<B>どなたかTHETA本体だけで完結する発話認識プラグインを作ってくださいっ！</B>

あとは、HID Remoteで作った部品はなかなか便利そうですので
うまいこと使っていただいたり、改善していただいたり、できることを増やしていただければと思います。

## RICOH THETAプラグインパートナープログラムについて
THETAプラグインをご存じない方は[こちら](https://github.com/ricohapi/theta-api-specs/blob/main/core/products/theta-plugin.md)をご覧ください。
パートナープログラムへの登録方法は[こちら](https://qiita.com/mShiiina/items/55d98f366e650ca42251)にもまとめてあります。
興味を持たれた方は[Twitter](https://twitter.com/thetaplugin)のフォローと[THETAプラグイン開発コミュニティ(Slack)](https://join.slack.com/t/thetadeveloper/shared_invite/enQtNzcxODI2MTk2ODg0LWY5YmU2ZTIxNGYwNzlmNWZjMWMyMWFlMzY3ZmQ3NGU3ZjZiZjI0Njk4MGIxMDU5NDJiMzQ4Y2FkNDEzYmFlNGU)への参加もよろしくおねがいします｡