音声強化版PaPeRo i のアプリをGolangでつくる

 音声強化版PaPeRo i ではPythonとpypaperoライブラリが前提となっており(音声強化版PaPeRo i で音声認識を行う参照)、Golangから利用する手段がありませんでしたが音声強化版PaPeRo i の音声認識アプリをリモートホストで動かす過程でTinker Board S 上でPythonのサーバを動かしてWebSocket通信で利用できる様にしたので、Golangでもこのサーバに接続するクライアントを作れば使えるようになるはずです。今回はこれを試してみましたのでご紹介します。
 なおパペロ本体でアプリを動かす場合はGolangにはパペロ本体にPython自体をインストールする時間分セットアップ時間を短くできる利点があったのですが、音声強化版PaPeRo i ではTinker Board S にあらかじめPythonがインストール済みなのでその利点はなく、あくまでもPythonよりもGolangで作りたいという場合のための情報になります。

WebSocketクライアント

 WebSocketクライアントにはこちらと同様にGorilla WebSocketを使用しました。接続はdialer.Dial()に接続先のURLを渡すだけです。送受信のAPIはブロッキング動作なのでgoroutineにしてアプリのメインプログラムとはchannel経由でやりとりするようにします。

WebSocketの送受信処理

 今回Python版のサーバ・クライアント間の通信フォーマットはJSONに変換した辞書としており、{“Name”: メッセージID}は共通であとはそのメッセージごとのパラメータを文字列、数値、真偽値として載せていました。GolangはPythonに較べてJSONの扱いが少し面倒で、送信側ではmap[string]interface{}の型で渡してやれば値の型が文字列、数値、真偽値と異なる場合もJSONに変換可能であるのに対し、受信では各キーについて型が定義されているstructを用意してそれに変換する必要があります。幸い同じキーで型が違うという事は無かったので、面倒を減らすため全部のAPI分まとめて一つのstructとして定義し、受信時にはこのstructに変換しています。

type WsCliMsg struct {
    Name       string
    Sentence   string
    ConfigFile string
    ConfigData string
    EarLED     bool
    HeadMove   bool
    MouthLED   bool
    CheekLED   bool
    ChatTalk   bool
    Talk       bool
    Volume     int
    Width      int
    Angle      int
    Vertical   string
    Horizontal string
    Message    string
    Module     string
    Plugin     string
    Attr       string
    Value      bool
    Ntime      int
    Ncount     int
    Bool       bool   `json:"bool"`
    Int        int    `json:"int"`
    Text       string `json:"text"`
    AttrType   string // config item
}

オプション引数

 Golangはデフォルト値が定義できて省略可能なオプション引数をサポートしておらず、Pythonで

def init(papero, *, config_file=None, config_data=None)
    ....

def start_listening(self, chat_talk=True):
    ...

等と宣言されるのと同等のAPIをサポートするためには工夫が必要です。通常エラーの扱いの観点からfunctional optionsと呼ばれる引数に関数を渡す方法が推奨される様なのですが、少しコードが複雑になること、また特にエラーを考慮する必要は無さそうなことから今回はメソッドチェーンを使用しました。厳密にやるのであれば個々のAPIごとにそのオプション引数のデータを保持するstructを定義するのですが、ここでも簡単のため全APIを一つのstructにまとめてしまいました。

// オプション値を保持するstruct
type PapebotOpt struct {
    configFile string
    configData string
    chatTalk   bool
    talk       bool
    width      int
    widthValid bool
    angle      int
    angleValid bool
    ntime      int
    ncount     int
}

// デフォルト値を設定したオブジェクトを作る
func NewPapebotOpt() *PapebotOpt {
    res := PapebotOpt{
        talk:     true,
        ntime:    30,
        ncount:   2,
    }
    return &res
}

// オプション値設定メソッド
func (p *PapebotOpt) ConfigFile(v string) *PapebotOpt {
    p.configFile = v
    return p
}

// オプション値設定メソッド
func (p *PapebotOpt) ChatTalk(v bool) *PapebotOpt {
    p.chatTalk = v
    return p
}

// オプション値設定メソッド
func (p *PapebotOpt) Width(v int) *PapebotOpt {
    p.width = v
    p.widthValid = true
    return p
}
...

オプション値指定メソッドはレシーバに値を設定しつつ自分自身をreturnしているのでメソッドチェーンで連続で値を設定できることになります。

APIの実装

 一例としておしゃべり発話実行APIは以下の様な実装になりました。

type MapStrAny map[string]interface{}

// papebotメッセージ送信
func (cli *PapebotClient) sendMsg(dic MapStrAny) {
    cli.conn.sendCh <- dic
}

// DoChatbotOperation papebotおしゃべり発話実行メッセージ送信
func (cli *PapebotClient) DoChatbotOperation(sentence string, opt *PapebotOpt) {
    dic := MapStrAny{KeyName: MID_DO_CHATBOT_OPERATION, KeySentence: sentence}
    if opt == nil {
        opt = NewPapebotOpt()
        logger.Info().Bool("talk", opt.talk).Msg("DoChatbotOperation() without opt.")
    } else {
        logger.Info().Bool("talk", opt.talk).Msg("DoChatbotOperation().")
    }
    dic[KeyTalk] = opt.talk
    cli.sendMsg(dic)
}

呼び出しは以下の様になります。

cli.DoChatbotOperation("ラーメン食べたいね", NewPapebotOpt().Talk(false))

サンプルメインプログラム

 Python版と同様、音声認識した文字数が少ないとオウム返し、多いときはおしゃべり機能の発話をするサンプルプログラムです。状態変数の型StateCodeStにその状態のイベント処理関数を結びつける方法で状態遷移プログラムを構成しています。

main.go:

package main

import (
    "flag"
    "os"

    "github.com/rs/zerolog"

    yp "local/yapapero"
)

// StateCodeSt アプリの「状態変数」用struct。各状態のイベントハンドラを持たせる
type StateCodeSt struct {
    Name        string                                                       // 状態名
    HandleEvent func(papeMsg yp.MsgIf, botMsg *WsCliMsg) bool                // この状態でのイベントハンドラ
    Enter       func(papeMsg yp.MsgIf, botMsg *WsCliMsg, oldState StateCode) // この状態に入るときの処理
    Leave       func(papeMsg yp.MsgIf, botMsg *WsCliMsg, newState StateCode) // この状態を出るときの処理
}

func (c *StateCodeSt) String() string {
    r := c.Name
    return r
}

// 状態変数の型
type StateCode *StateCodeSt

// アプリケーションクラス
type App struct {
    State   StateCode // 現状態
    papeUrl string
    botUrl  string
    papero  *yp.Papero     // パペロ
    botcli  *PapebotClient // papebotクライアント
    // フィールド名を状態名として使う
    StateInit   StateCode // 初期状態
    StateIdle   StateCode // アイドル状態
    StateChat   StateCode // 返答問い合わせ中状態
    StateSpeech StateCode // 発話中状態
}

// NewApp new app
func NewApp() *App {
    app := &App{}
    app.Init()
    return app
}

// Init アプリ初期化
func (app *App) Init() {
    app.botcli = NewPapebotClient()
    // 状態情報保持メンバーにイベントハンドラを登録
    app.StateInit = &StateCodeSt{Name: "appInit", HandleEvent: app.InInit}       // 初期状態
    app.StateIdle = &StateCodeSt{Name: "appIdle", HandleEvent: app.InIdle}       // アイドル
    app.StateChat = &StateCodeSt{Name: "appChat", HandleEvent: app.InChat}       // 返答をpapebotserverに問い合わせ中
    app.StateSpeech = &StateCodeSt{Name: "appSpeech", HandleEvent: app.InSpeech} // 発話中(mute制御は自分でやる)
    app.State = app.StateInit
}

// アプリの状態遷移
func (app *App) TransState(papeMsg yp.MsgIf, botMsg *WsCliMsg, newst StateCode) {
    logger.Info().Str("from", (*app.State).String()).Str("to", (*newst).String()).Msg("trans state.")
    oldst := app.State
    if (*oldst).Leave != nil {
        // 旧状態を出るときの処理
        (*oldst).Leave(papeMsg, botMsg, newst)
    }
    app.State = newst
    if (*newst).Enter != nil {
        // 新状態に入るときの処理
        (*newst).Enter(papeMsg, botMsg, oldst)
    }
    // 一覧性を重視する場合(複数状態共通の短い処理)ここに記述
    switch newst {
    case app.StateInit:
    case app.StateIdle:
    case app.StateChat:
    case app.StateSpeech:
    }
}

// 初期状態イベント処理
func (app *App) InInit(papeMsg yp.MsgIf, botMsg *WsCliMsg) bool {
    res := true
    switch {
    case (papeMsg != nil) && papeMsg.IsReady():
        logger.Info().Msg("PaperoReady")
        // 状態遷移
        app.TransState(papeMsg, botMsg, app.StateIdle)
    }
    return res
}

// アイドル状態イベント処理
func (app *App) InIdle(papeMsg yp.MsgIf, botMsg *WsCliMsg) bool {
    res := true
    name := ""
    isPape := papeMsg != nil
    isBot := botMsg != nil
    if isPape {
        name = papeMsg.GetName()
    } else if isBot {
        name = botMsg.Name
    }
    switch {
    case name == MID_RECOGNIZED:
        txt := botMsg.Sentence
        logger.Info().Str("name", name).Str("txt", txt).Msg("rcv.")
        if 16 < len(txt) {
            app.botcli.DoChatbotOperation(txt, NewPapebotOpt().Talk(false))
            app.botcli.VoiceMute()
            app.TransState(papeMsg, botMsg, app.StateChat)
        } else if 0 < len(txt) {
            app.papero.Speech(txt+"ってなんのこと?", nil)
            app.botcli.VoiceMute()
            app.TransState(papeMsg, botMsg, app.StateSpeech)
        }
    default:
        logger.Info().Str("name", name).Msg("rcv")
    }
    return res
}

// 返答問い合わせ中状態イベント処理
func (app *App) InChat(papeMsg yp.MsgIf, botMsg *WsCliMsg) bool {
    res := true
    name := ""
    isPape := papeMsg != nil
    isBot := botMsg != nil
    if isPape {
        name = papeMsg.GetName()
    } else if isBot {
        name = botMsg.Name
    }
    switch {
    case name == MID_DO_CHATBOT_OPERATION_RES:
        txt := botMsg.Text
        logger.Info().Str("name", name).Str("txt", txt).Msg("rcv.")
        if 0 < len(txt) {
            app.papero.Speech(txt, nil)
            app.TransState(papeMsg, botMsg, app.StateSpeech)
        } else {
            app.botcli.VoiceUnmute()
            app.TransState(papeMsg, botMsg, app.StateIdle)
        }
    default:
        logger.Info().Str("name", name).Msg("rcv")
    }
    return res
}

// 発話中状態イベント処理
func (app *App) InSpeech(papeMsg yp.MsgIf, botMsg *WsCliMsg) bool {
    res := true
    name := ""
    isPape := papeMsg != nil
    isBot := botMsg != nil
    if isPape {
        name = papeMsg.GetName()
    } else if isBot {
        name = botMsg.Name
    }
    switch {
    case isPape && papeMsg.IsEndOfSpeech():
        logger.Info().Msg("end of speech.")
        app.botcli.VoiceUnmute()
        // 状態遷移
        app.TransState(papeMsg, botMsg, app.StateIdle)
    case isBot && (name == MID_CHAT_TALK_END):
        // 状態遷移
        app.TransState(papeMsg, botMsg, app.StateIdle)
    case isPape && papeMsg.IsGetSpeechStatusRes():
        // ポーリング返答
    case isPape && papeMsg.IsTimerMsg("0"):
        // ポーリングタイマー
    default:
        logger.Info().Str("name", name).Msg("rcv")
    }
    return res
}

// appHandler application level event handler
func (app *App) appHandler() bool {
    var papeMsg yp.MsgIf
    var botMsg *WsCliMsg
    papeMsg = nil
    botMsg = nil
    select {
    case papeMsg = <-app.papero.Ch:
    case botMsg = <-app.botcli.RecvCh:
    }
    var res bool = true
    //logger.Info().Bool("isBot", isBot).Bool("isPape", isPape).Msg("recv msg.")
    if (*app.State).HandleEvent == nil {
        logger.Error().Str("State", (*app.State).String()).Msg("handleEvent is not defined")
    } else {
        res = (*app.State).HandleEvent(papeMsg, botMsg)
    }
    return res
}

// loop event loop
func (app *App) loop() {
    for {
        if !app.appHandler() {
            break
        }
    }
}

// start アプリケーション開始
func (app *App) Start(papeurl, boturl string) {
    logger.Info().Msg("app start.")
    app.papeUrl = papeurl
    app.botUrl = boturl
    //triggerword := true // 「パペロ」必要
    triggerword := false                          // 「パペロ」不要
    moduleList := StrList{"weather", "wikipedia"} // 追加モジュール
    go app.botcli.Run(boturl, triggerword,
        NewPapebotOpt().ModuleList(moduleList))
    app.papero = yp.NewPapero(papeurl, "", "")
    app.papero.Start()
    app.loop()
}

// main メイン
func main() {
    //log.SetFlags(log.LstdFlags | log.Lmicroseconds | log.Lshortfile)
    //loglevel := zerolog.DebugLevel
    loglevel := zerolog.InfoLevel
    logger = InitPrettyLogger(os.Stdout, loglevel)
    SetLogger(logger)
    var papeaddr, botaddr *string
    papeaddr = flag.String("papeaddr", "ws://192.168.5.1:8088/papero", "papero ws address")
    botaddr = flag.String("botaddr", "ws://192.168.5.100:8867/papebot", "papebot server address")
    flag.Parse()
    app := NewApp()
    app.Start(*papeaddr, *botaddr)
}

ソースはこちらからダウンロードできます。


0