音声強化版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)
}
ソースはこちらからダウンロードできます。