Emacs に Google Assistant を搭載するハナシ
どうも僕です。
ところで、みなさんの使ってるエディターは AI 搭載していますか?
私の Emacs にはある!
と言いましたが AI というのは煽りです。
ですが、Goolgle Assistant を Emacs に降臨させることは可能です。ハイ。
実はテキストで答えるだけではなく音声でも答えてくれます。
ということで今回は誰の役にも立たない Emacs に Google Assistant を搭載するハナシです。
Google Assistant をどうやって Emacs に?
さて、問題です。どうやって Google Assistant をエディタに組み込めばいいでしょうか?
簡単にできる話であれば melpa
にパッケージがありそうです。私も少し探してみたのですがそういうことをやってる方は見つけることができませんでした。
実現方法は恐らくひとつです。
- Google Assistant API を使って Assistant とやりとりする
- その処理をなんとかして Emacs から呼び出す
あまり知られていないかも知れませんが、Google Assistant には SDK があり、全ての機能ではないにしろ、SDK 経由で Assistant と会話ができます。
(私は Android Things でこの機能を使っていたので知っていた)
ですがまともな SDK は Python しか存在せず、その他の言語では API でやり取りするしかありません。この API は gRPC なので、elisp では厳しそうです。
Assistant とやりとりするプログラムを作り、そのプロセスを立ち上げ、そのプロセスとネットワーク経由でやりとり…となると実装量も増え、めんどくさそうです。なによりめんどくさいのがわかってしまうとやる気すらでません。
シンプルに実現する方法はないでしょうか?
実はあります。dynamic module を使えばいいのです。
Dynamic Modules
Emacs には 25.1 から dynamic modules という機能が追加されています。
これは、Emacs Lisp で作成されたパッケージと同様に、Emacs Lisp プログラムで使用するための追加機能を提供する共有ライブラリを、読み込む機能です。
早い話、 .so
が Emacs からロードできるということです。
この機能を使うことで以下のことが可能になります。
- elisp 以外の言語でのパッケージの開発
- 既存の elisp のみでは実現できない機能の追加
- elisp よりも高速に動作
Emacs の dynamic module のお作法に従った共有ライブラリさえ作れれば module-load
関数で簡単に読み込むことができるのです。
以下はサンプルコードです。
#include <emacs-module.h>
/* Declare mandatory GPL symbol. */
int plugin_is_GPL_compatible;
/* New emacs lisp function. All function exposed to Emacs must have this prototype. */
static emacs_value
Fmymod_test (emacs_env *env, int nargs, emacs_value args[], void *data)
{
return env->make_integer (env, 42);
}
/* Bind NAME to FUN. */
static void
bind_function (emacs_env *env, const char *name, emacs_value Sfun)
{
/* Set the function cell of the symbol named NAME to SFUN using
the 'fset' function. */
/* Convert the strings to symbols by interning them */
emacs_value Qfset = env->intern (env, "fset");
emacs_value Qsym = env->intern (env, name);
/* Prepare the arguments array */
emacs_value args[] = { Qsym, Sfun };
/* Make the call (2 == nb of arguments) */
env->funcall (env, Qfset, 2, args);
}
/* Provide FEATURE to Emacs. */
static void
provide (emacs_env *env, const char *feature)
{
/* call 'provide' with FEATURE converted to a symbol */
emacs_value Qfeat = env->intern (env, feature);
emacs_value Qprovide = env->intern (env, "provide");
emacs_value args[] = { Qfeat };
env->funcall (env, Qprovide, 1, args);
}
int
emacs_module_init (struct emacs_runtime *ert)
{
emacs_env *env = ert->get_environment (ert);
/* create a lambda (returns an emacs_value) */
emacs_value fun = env->make_function (env,
0, /* min. number of arguments */
0, /* max. number of arguments */
Fmymod_test, /* actual function pointer */
"doc", /* docstring */
NULL /* user pointer of your choice (data param in Fmymod_test) */
);
bind_function (env, "mymod-test", fun);
provide (env, "mymod");
/* loaded successfully */
return 0;
}
emacs-
から始まる関数が Emacs とやりとりするための関数ですがそんなに数が多くないことがわかると思います。
あとはこれを共有ライブラリとしてコンパイルできれば OK です。
詳しくは Emacs のドキュメント、Dynamic Modules の章を参照してください。
Go での共有ライブラリの作成
Dynamic module で Assistant API を呼出すればシンプルに呼び出せそうなのはわかりました。
では C と親和性が高く、共有ライブラリを作成できる言語といえばなんでしょうか?
- Go
- Rust
この二つが候補に挙げられます。
Rust は Go に較べてライブラリが充実しておらず gRPC 呼出に不安があります。
そのため、今回は Go で開発することとします。
Emacs の API とのバインディングは実は既に存在しています。 メンテが止まっているようなので何かあればすぐ修正できるよう fork して最新の Emacs のヘッダを取り込んだ emacs-module-go を今回は使用します。
バインディングさえあればあとはなんとかなりそうです。
あと当たり前ですが、Emacs の関数を別スレッド(goroutine)から呼び出すと panic が発生します。スレッドセーフを心がけるようにしないといけません。Go 内で完結している範囲であれば goroutine を使用しても OK です。chan などで処理を待ち合わせて Emacs と足並みを揃えるようコーディングしていく必要があります。
ビルドは Go の場合 -buildmode
を指定すればいいだけなので簡単です。
go build -buildmode=c-shared -ldflags -s -o libassistant.so main.go
Google Assistant API
Google Assistant を使用するには GCP のプロジェクトを用意するなどの準備が必要になります。
その辺は Google Assistant のチュートリアルに従って作成して下さい。
まずは API がどのようなものかドキュメントを確認する必要があります。
詳細は SDK の Reference のページで確認できます。
私が使っていた頃は alpha1
でしたが最新は v1alpha2
のようです。
ドキュメントにもあるように gRPC を使っているので API にあった protobuf も手に入れる必要があります。
Google API の定義は GitHub にありますが、コードの自動生成で手間がかかるらしく、Go のクライアントは別リポジトリにまとめてあります。
これを使って実装を進めます。
ドキュメントを眺めていても使い方がイマイチよくわからないかも知れません。 アシスタントを使用する際の処理は以下のように実装する必要があります。
- OAuth 認証でトークンを得る
- 入力の形式を決めてリクエストを作成する
- 認証情報をセットしたクライアントでリクエストを送信する
- レスポンスを受け取る
- レスポンス内にある音声データを鳴らす
Assistant API のリクエストは 2 タイプをサポートし、設定(AssistantConfig)で切り替えることができます。以下 2 つのタイプがあります。
- Google Nest などと同様音声入力(録音した音声データのバイト列)
- テキストクエリ(テキストデータ)
リクエストには Assistant への要求をダイレクトに入れます。
明日の天気を聞きたい場合は 明日の天気は?
と録音した音声データ、あるいは 明日の天気は?
というテキストです。直感的です。
音声入力はマイク入力から録音データをバッファにためたりするわけですが、Emacs 側からのトリガーがイマイチ扱いづらいので素直にテキストクエリを使います。
ちなみに音声入力にすると内容が書き起こされ、そのテキストも入手することができます。
(かなりの精度で認識されるので見てみるとおもしろいです)
assistant := embedded.NewEmbeddedAssistantClient(conn)
config := &embedded.AssistConfig{
AudioOutConfig: &embedded.AudioOutConfig{
Encoding: embedded.AudioOutConfig_LINEAR16,
SampleRateHertz: 16000,
VolumePercentage: 100,
},
DialogStateIn: &embedded.DialogStateIn{
LanguageCode: "ja-JP",
ConversationState: nil,
IsNewConversation: true,
},
DeviceConfig: &embedded.DeviceConfig{
DeviceId: "my-emacs",
DeviceModelId: "emacs",
},
Type: &embedded.AssistConfig_TextQuery{
TextQuery: text,
},
DebugConfig: &embedded.DebugConfig{
ReturnDebugInfo: true,
},
}
// 初期化処理など....
if err := client.Send(&embedded.AssistRequest{
Type: &embedded.AssistRequest_Config{
Config: config,
},
}); err != nil {
return "", errors.Wrap(err, "failed send")
}
リクエストを送信し、レスポンスを受け取ります。 レスポンスには以下の情報が含まれています。
- 音声データ
- テキストデータ
- 会話の状態
正しく Assistant が処理できた場合にはテキストデータが存在します。機能の制限などのため、うまく処理できなかった場合にはテキストデータが含まれてこないことがあります。
成功失敗どちらのケースでも音声データは返ってくるようです。
テキストが含まれない失敗のレスポンスの場合、日本語では お役に立てそうにありません
などといった音声データが返ってきます。
そのため、なるべくならレスポンスの音声は再生した方が良さそうです。
responseText := ""
for {
resp, err := client.Recv()
if err == io.EOF {
break
}
if err != nil {
return "", errors.Wrap(err, "failed recv")
}
if resp.EventType == embedded.AssistResponse_END_OF_UTTERANCE {
log.Debug().Msg("END_OF_UTTERANCE")
}
displayText := resp.GetDialogStateOut().GetSupplementalDisplayText()
if resp.GetDialogStateOut() != nil {
if responseText == "" {
responseText = displayText
}
if textOnly {
if responseText == "" {
responseText = "お役に立てそうもありません"
}
log.Debug().Str("responseText", responseText).Msg("")
return responseText, nil
}
}
audioOut := resp.GetAudioOut()
if audioOut != nil {
signal := bytes.NewBuffer(audioOut.GetAudioData())
var err error
for err == nil {
err = binary.Read(signal, binary.LittleEndian, bufOut)
if err != nil {
break
}
if portErr := streamOut.Write(); portErr != nil {
log.Error().Err(err).Msg("failed to write to audio out")
}
}
}
}
あとはレスポンスのテキストデータを *Message*
バッファに出すなり、コールバックに渡すなりすれば OK です。
最後に
思ったより簡単に Assistant 機能を追加することができました。Emacs の可能性が無限大であることがわかりましたね!!! 皆さんもいろんな Module を開発してみてはいかがでしょうか?
おまけのコード
最後に参考までにコードの全体を載せておきます。
oatuth.go
package assistant
import (
"context"
"encoding/json"
"net/http"
"os"
"os/exec"
"runtime"
"time"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"golang.org/x/oauth2"
)
var (
oauthToken *oauth2.Token
gcp *gcpAuthWrapper
oauthSrv *http.Server
oauthRedirectURL = "http://localhost:8080"
oauthTokenFilename = "oauthToken.cache"
)
type JSONToken struct {
Installed struct {
ClientID string `json:"client_id"`
ProjectID string `json:"project_id"`
AuthURI string `json:"auth_uri"`
TokenURI string `json:"token_uri"`
AuthProviderX509CertURL string `json:"auth_provider_x509_cert_url"`
ClientSecret string `json:"client_secret"`
RedirectUris []string `json:"redirect_uris"`
} `json:"installed"`
}
type gcpAuthWrapper struct {
Conf *oauth2.Config
}
func NewGCPAuthWrapper() *gcpAuthWrapper {
if gcp != nil {
return gcp
}
gcp = &gcpAuthWrapper{}
return gcp
}
func (w *gcpAuthWrapper) Auth(credPath string) error {
f, err := os.Open(credPath)
if err != nil {
panic(err)
}
defer f.Close()
var token JSONToken
if err = json.NewDecoder(f).Decode(&token); err != nil {
return errors.Wrap(err, "failed to decode json token")
}
w.Conf = &oauth2.Config{
ClientID: token.Installed.ClientID,
ClientSecret: token.Installed.ClientSecret,
Scopes: []string{"https://www.googleapis.com/auth/assistant-sdk-prototype"},
RedirectURL: oauthRedirectURL,
Endpoint: oauth2.Endpoint{
AuthURL: "https://accounts.google.com/o/oauth2/auth",
TokenURL: "https://accounts.google.com/o/oauth2/token",
},
}
// check if we have an oauth file on disk
if hasCachedOauth() {
err = loadTokenSource()
if err == nil {
// ok
log.Info().Msg("You have successfully authenticated")
return nil
}
log.Info().Str("error", err.Error()).Msg("Failed to load the token source")
}
url := w.Conf.AuthCodeURL("state", oauth2.AccessTypeOffline)
// There are no plans to support Windows.
if runtime.GOOS != "darwin" {
cmd := exec.Command("xdg-open", url)
cmd.Run()
} else {
cmd := exec.Command("open", url)
cmd.Run()
}
oauthSrv = &http.Server{Addr: ":8080", Handler: http.DefaultServeMux}
http.HandleFunc("/", oauthHandler)
if err = oauthSrv.ListenAndServe(); err != http.ErrServerClosed {
return errors.Wrap(err, "listen: %s")
}
log.Info().Msg("You have successfully authenticated")
return nil
}
func oauthHandler(w http.ResponseWriter, r *http.Request) {
permissionCode := r.URL.Query().Get("code")
setTokenSource(permissionCode)
http.Redirect(w, r, "http://google.com", http.StatusTemporaryRedirect)
go func() {
time.Sleep(time.Second * 2)
oauthSrv.Shutdown(context.Background())
}()
}
func hasCachedOauth() bool {
if _, err := os.Stat(oauthTokenFilename); os.IsNotExist(err) {
return false
}
return true
}
func setTokenSource(permissionCode string) {
var err error
ctx := context.Background()
oauthToken, err = gcp.Conf.Exchange(ctx, permissionCode)
if err != nil {
log.Fatal().Err(err).Msg("failed to retrieve the oauth2 token")
}
//fmt.Println(oauthToken)
of, err := os.Create(oauthTokenFilename)
if err != nil {
log.Panic().Err(err).Msg("failed to retrieve the oauth2 token")
}
defer of.Close()
if err = json.NewEncoder(of).Encode(oauthToken); err != nil {
log.Panic().Err(err).Msg("Something went wrong when storing the token source")
}
}
func loadTokenSource() error {
f, err := os.Open(oauthTokenFilename)
if err != nil {
return errors.Wrap(err, "failed to load the token source (deleted from disk)")
}
defer f.Close()
var token oauth2.Token
if err = json.NewDecoder(f).Decode(&token); err != nil {
return err
}
oauthToken = &token
return nil
}
assistant.go
package assistant
import (
"bytes"
"context"
"encoding/binary"
"io"
"time"
"github.com/gordonklaus/portaudio"
"github.com/mopemope/emacs-module-go"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
"google.golang.org/api/option"
"google.golang.org/api/transport"
embedded "google.golang.org/genproto/googleapis/assistant/embedded/v1alpha2"
"google.golang.org/grpc"
)
var (
conversationState []byte
)
func AuthGCP(ctx emacs.FunctionCallContext) (emacs.Value, error) {
env := ctx.Environment()
stdlib := env.StdLib()
cred := viper.GetString("assistant.credentials")
gcp := NewGCPAuthWrapper()
if err := gcp.Auth(cred); err != nil {
return stdlib.Nil(), errors.Wrap(err, "")
}
return stdlib.T(), nil
}
func Ask(ctx emacs.FunctionCallContext) (emacs.Value, error) {
env := ctx.Environment()
stdlib := env.StdLib()
text, err := ctx.GoStringArg(0)
if err != nil {
return stdlib.Nil(), errors.Wrap(err, "")
}
textOnly := false
value := ctx.Arg(1)
if value.IsT() {
textOnly = true
}
res, err := ask(text, textOnly)
if err != nil {
return stdlib.Nil(), errors.Wrap(err, "")
}
return env.String(res), nil
}
func newConn(ctx context.Context) (*grpc.ClientConn, error) {
tokenSource := gcp.Conf.TokenSource(ctx, oauthToken)
return transport.DialGRPC(ctx,
option.WithTokenSource(tokenSource),
option.WithEndpoint("embeddedassistant.googleapis.com:443"),
option.WithScopes("https://www.googleapis.com/auth/assistant-sdk-prototype"),
)
}
func ask(text string, textOnly bool) (string, error) {
portaudio.Initialize()
defer portaudio.Terminate()
cred := viper.GetString("assistant.credentials")
gcp := NewGCPAuthWrapper()
if err := gcp.Auth(cred); err != nil {
return "", errors.Wrap(err, "")
}
ctx := context.Background()
runDuration := 240 * time.Second
ctx, _ = context.WithDeadline(ctx, time.Now().Add(runDuration))
conn, err := newConn(ctx)
if err != nil {
return "", errors.Wrap(err, "failed to acquire connection")
}
defer conn.Close()
assistant := embedded.NewEmbeddedAssistantClient(conn)
config := &embedded.AssistConfig{
AudioOutConfig: &embedded.AudioOutConfig{
Encoding: embedded.AudioOutConfig_LINEAR16,
SampleRateHertz: 16000,
VolumePercentage: 100,
},
DialogStateIn: &embedded.DialogStateIn{
LanguageCode: "ja-JP",
ConversationState: nil,
IsNewConversation: true,
},
DeviceConfig: &embedded.DeviceConfig{
DeviceId: "my-emacs",
DeviceModelId: "emacs",
},
Type: &embedded.AssistConfig_TextQuery{
TextQuery: text,
},
DebugConfig: &embedded.DebugConfig{
ReturnDebugInfo: true,
},
}
bufOut := make([]int16, 800)
streamOut, err := portaudio.OpenDefaultStream(0, 1, 16000, len(bufOut), &bufOut)
defer func() {
if err := streamOut.Close(); err != nil {
//
}
}()
if err = streamOut.Start(); err != nil {
log.Panic().Err(err).Msg("")
}
client, err := assistant.Assist(ctx)
if err != nil {
return "", errors.Wrap(err, "failed assist")
}
log.Debug().Msgf("ask: %s", text)
if err := client.Send(&embedded.AssistRequest{
Type: &embedded.AssistRequest_Config{
Config: config,
},
}); err != nil {
return "", errors.Wrap(err, "failed send")
}
if !textOnly {
portaudio.Initialize()
defer portaudio.Terminate()
}
responseText := ""
for {
resp, err := client.Recv()
if err == io.EOF {
break
}
if err != nil {
return "", errors.Wrap(err, "failed recv")
}
if resp.EventType == embedded.AssistResponse_END_OF_UTTERANCE {
log.Debug().Msg("END_OF_UTTERANCE")
}
// log.Debug().Msgf("## %+v %+v %+v", resp.GetDebugInfo(), resp.GetDeviceAction(), resp.GetSpeechResults())
displayText := resp.GetDialogStateOut().GetSupplementalDisplayText()
if resp.GetDialogStateOut() != nil {
if responseText == "" {
responseText = displayText
}
if textOnly {
if responseText == "" {
responseText = "お役に立てそうもありません"
}
log.Debug().Str("responseText", responseText).Msg("")
return responseText, nil
}
}
audioOut := resp.GetAudioOut()
if audioOut != nil {
signal := bytes.NewBuffer(audioOut.GetAudioData())
var err error
for err == nil {
err = binary.Read(signal, binary.LittleEndian, bufOut)
if err != nil {
break
}
if portErr := streamOut.Write(); portErr != nil {
log.Error().Err(err).Msg("failed to write to audio out")
}
}
}
}
if responseText == "" {
responseText = "お役に立てそうもありません"
}
log.Debug().Str("responseText", responseText).Msg("")
return responseText, nil
}
main.go(抜粋)
package main
// int plugin_is_GPL_compatible;
import "C"
import (
"xxxxx/assistant"
"os"
"path/filepath"
"strings"
"github.com/mopemope/emacs-module-go"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
)
func init() {
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_"))
viper.AutomaticEnv()
zerolog.SetGlobalLevel(zerolog.InfoLevel)
configDir, err := os.UserConfigDir()
if err != nil {
log.Error().Msg(err.Error())
} else {
cfgFile := filepath.Join(configDir, "config.toml")
if fileExists(cfgFile) {
viper.SetConfigFile(cfgFile)
if err := viper.ReadInConfig(); err != nil {
log.Error().Msg(err.Error())
} else {
log.Debug().Msgf("readed config %s", cfgFile)
}
}
}
initLogger()
emacs.Register(initModule)
}
func fileExists(filename string) bool {
info, err := os.Stat(filename)
if os.IsNotExist(err) {
return false
}
return !info.IsDir()
}
func initModule(env emacs.Environment) {
log.Debug().Msg("initializing ...")
stdlib := env.StdLib()
{
// assistant
env.RegisterFunction("assistant-auth", assistant.AuthGCP, 0, "doc", nil)
env.RegisterFunction("assistant-ask", assistant.Ask, 2, "doc", nil)
}
stdlib.Message("loaded assistant module")
env.ProvideFeature("assistant")
}
func initLogger() {
debug := viper.GetBool("debug")
if debug {
zerolog.SetGlobalLevel(zerolog.DebugLevel)
}
}
func main() {
}