となりのJohnの気まぐれ

気の向くままに

【音声プログラミング】Google Home "と" プログラミング

これは何?

とある技術系記事をまとめたQiitaに私が過去に投稿した内容の移植記事.

成果物をアップすることに関して個人的な考えを述べると,Qiitaには集合知として技術的に専門性が高く,汎用性が高い記事が投稿されるべきである.特に「チュートリアルをやってみた」とか,「~をつくってみた」という記事で,扱われている技術やノウハウが汎用的でない/重複していると,記事が飽和し肝心の技術的知識/ノウハウへの到達が難しくなる.

でも成果物はアウトプットしたいというのが人情というものだ.
Qiitaは界隈で圧倒的知名度を誇る.なかなか侮りがたい魅力がある.
しかし私はアウトプットのためのメディアとしてブログを選ぶことにした…

バランスが難しい.
純粋に技術に興味がある人にとっては上記のは害だけどそうでない人もいる…住み分けが必要なんじゃないの,という話か.

今回の記事の内容は音声認識結果をスプレッドシートに書き込む等正直変な実装をしているので,一部参考にしない方がいい箇所があると思う.正規表現をキーとした分岐の記述は参考になると思う.

概要

Google Homeを使いこなせていない私が,音声入力でC++の初歩的なプログラミングをしたはなし.ハンズフリーなプログラミングができるようになる.

寝ころびながら,ディスプレイの前を右往左往しながらプログラミングする人には朗報!
実用的かどうかは疑問点が残るが

構成

f:id:newbie29979:20200105004613p:plain
音声プログラミング

  1. Google Homeによる音声認識結果をIFTTT経由でスプレッドシートに送る
  2. スプレッドシートの内容をGASでRSSに変換
  3. クライアントはRSSの更新を確認後,認識結果を解析
  4. 解析結果に応じVimベースでキー入力

わざわざRSSに変換しているのは,スプレッドシートを扱うpythonのモジュールでアカウント認証が必要だったため.今回の場合スプレッドシートの読み込みさえできれば問題がないので,認証を行わずRSSに変換後パースする.

音声入力

f:id:newbie29979:20200105004533p:plain
音声入力

Google Homeへのコマンド

最初に,Google Homeへの入力をIFTTT経由で送る.

f:id:newbie29979:20200105004643j:plain
IFTTTによる連携
Google HomeとIFTTTとの連携については,調べればいくらでもヒットするので割愛

今回は「Ok,Google そこで $」という発話をGoogle Homeが認識したときにこのアプレットが動作するように設定している.$には「そこで」の後にGoogle Home音声認識した結果が入る.本当は「Ok,Google $」だけで済まされるのが一番カッコいいと思うが,それはできないようになっている.当初「Ok,Googleぅ」との認識を期待して「う $」というコマンドを考えていたが,動作はしなかった.

「そこで」には

  1. 「その行で」式文を書くという編集箇所を指定
  2. 「そこで」行移動するという話題転換

の2つの意味を持たせている.

要するにGoogle Homeとの自然な対話を目指していたのだが,難しいところ.今後違和感なくスマートスピーカを,音声入力のために使用するときに重要な要素になると思う.

IFTTTアプレットが動作すると指定したスプレッドシートの新しい行に音声認識結果が追記されていくように設定する.

スプレッドシートRSSへの変換

スプレッドシートRSSに変換するために,以下のサイトを参考にGoogle App Scriptを記述した.
dokodemodoor-junk.net
RSSにシートの音声認識結果を記述するGASはこんな感じ.

var spreadSheet;
var recogResults;

function doGet() {
  var _ = Underscore.load();
  spreadSheet = SpreadsheetApp.getActiveSpreadsheet();
  recogResults = spreadSheet.getSheetByName('シート1').getRange("A:A").getValues();

  // 二次元配列を一次元に
  recogResults = _.flatten(recogResults);
  recogResults = recogResults.filter(function(str){
    return str != '';
  });

  var output = HtmlService.createTemplateFromFile('RSS');
  var result= output.evaluate();

  return ContentService.createTextOutput(result.getContent())
  .setMimeType(ContentService.MimeType.XML);
}


function getSpreadSheetTitle(){
  return spreadSheet.getName();
}
function getSpreadSheetLink(){
  return spreadSheet.getUrl();
}
function getRowData(rowNum){
  return recogResults[rowNum];
}
function getLastRow(){
  return recogResults.length;
}

getValues()でスプレッドシート内のデータを取得すると二次元配列で返ってくるが,今回は音声認識結果が1列だけ並んでいる状態なので,underscoreで次元を落としている.
github.com

RSSでは行番号をtitle音声認識結果をdescriptionに記述する.

<rss version="2.0">
<channel>
 <title><?= getSpreadSheetTitle() ?></title>
 <link><?= getSpreadSheetLink() ?></link>
 <description />

  <?
  var lastIndex = getLastRow()-1;
  if(getRowData(lastIndex)){
 ?>

 <item>
 <title><?= lastIndex ?></title>
 <description><?= getRowData(lastIndex) ?></description>
 </item>
 <? } ?>

</channel>
</rss>

行番号はスプレッドシートの更新を判定するために使う.
スプレッドシート111行目の音声認識結果「変数Googleに2を加算して」がRSSに反映されるとき,こんな感じで表示される.

f:id:newbie29979:20200105035921j:plain
認識結果のRSS

音声認識結果の解析

f:id:newbie29979:20200105040849p:plain
音声認識結果の解析

使用するモジュール

ここからはpythonを使ったモジュールの説明になる.モジュールは以下の通り.

pypi.org

  • PyAutoGUI:PC上の操作をスクリプトで制御するためのモジュール.

pyautogui.readthedocs.io

解析

特性上,入力によって変化する変数や数値をVimのコマンドに変換するため,正規表現を含んだキーを持つ連想配列を用意する.これはとても変わった試みのように思われるが,実際にはうまく機能する.これについては,以下のサイトを参考にした.
taichino.com

具体的な連想配列はこんな感じの宣言になる.enterescなど特殊なキーは音声入力によるプログラミングでは精々使わないであろう"`"で区切る.

{
  ".*いつもの": "ggi#include<iostream>`enter`int main(){`enter`using namespace std;`enter`enter`return 0;`enter`}`esc",
  ".*[a-z]+を使う": "ggi#include<${w1}>`enter`esc",
  ".*変数[a-z]+[0-9]*をプリント": "icout<<${w1};`enter`esc",
  ".*[a-z]+[0-9]*をプリント": "icout<<\"${w1}\";`enter`esc",
  ".*改行をプリント": "icout<<endl;`enter`esc",

  ".*[a-z]+[0-9]*に([0-9]+|[a-z]+[0-9]*)を代入": "i${w1}=${w2};`enter`esc",
  ".*[a-z]+[0-9]*に文字列([0-9]+|[a-z]+[0-9]*)を代入": "i${w1}=${w2};`enter`esc",
  ".*[a-z]+[0-9]*に([0-9]+|[a-z]+[0-9]*)を加算": "i${w1}+=${w2};`enter`esc",
  ".*[a-z]+[0-9]*から([0-9]+|[a-z]+[0-9]*)を引": "i${w1}-=${w2};`enter`esc",
  ".*[a-z]+[0-9]*に([0-9]+|[a-z]+[0-9]*)をかけ": "i${w1}*=${w2};`enter`esc",
  ".*[a-z]+[0-9]*を([0-9]+|[a-z]+[0-9]*)で割": "i${w1}/=${w2};`enter`esc",
  ".*[a-z]+[0-9]*の([0-9]+|[a-z]+[0-9]*)による剰余": "i${w1}%=${w2};`enter`esc",
  ".*変数[a-z]+[0-9]*.*": "iint ${w1};`enter`esc",

  ".*[a-z]+[0-9]*が([0-9]+|[a-z]+[0-9]*)より小さいなら": "iif(${w1}<${w2}){`enter``enter`}`esc",
  ".*[a-z]+[0-9]*が([0-9]+|[a-z]+[0-9]*)より大きいなら": "iif(${w1}>${w2}){`enter``enter`}`esc",
  ".*[a-z]+[0-9]*が([0-9]+|[a-z]+[0-9]*)と等しいなら": "iif(${w1}==${w2}){`enter``enter`}`esc",
  ".*それ以外": "ielse{`enter``enter`}`esc",

  ".*[0-9]+回繰り返し": "ifor(int i=0;i<${w1};++i){`enter`enter`}`esc",

  ".*[0-9]+行削除": "${w1}dd",
  ".*[0-9]+行ヤンク": "${w1}yy",
  ".*貼り付け": "p",
  ".*[0-9]+行目に移動": "${w1}G",
  ".*最初の行に移動": "gg",
  ".*最後の行に移動": "G",
  ".*改行": "o`esc",
  ".*自動整形": "gg=G"
}

Googleを宣言して」などでは,Vimのコマンド列中に変数名である文字列"Google"が含まれる必要がある.そこで,連想配列Valueにおける変数や数値は,テンプレートを用いた文字列生成によって実装する.

def set_input_words(_input_str, _command_vars_dict):
    words = [word for word in re.findall(r'[a-z]+[0-9]*|[0-9]+', _input_str) if word != '']
    if words:
        for i in range(len(words)):
            _command_vars_dict['w'+str(i+1)] = words[i]


def convert_speech_input(_speech_input):
    vars_dict = {}
    set_input_words(_speech_input, vars_dict)

    converted_command = string.Template(r_command_dict[_speech_input])
    return converted_command.safe_substitute(vars_dict)

convert_speech_input()は認識結果を引数として,認識結果をVimのコマンド列に変換する.こうして得られるコマンド列をPyAutoGUIで順に読み取っていけば音声入力で入力操作が可能になる.

スクリプトによる制御

いよいよ最終段階.
単純なキー入力と特殊キー入力とでメソッドが異なることに注意する.

            # Google Homeの半角単語は大文字で始まるため小文字に
            result = convert_speech_input(speech_input.lower()).split('`')

            reserved_word = ['enter', 'esc']
            for word in result:
                if word in reserved_word:
                    pyautogui.press(word)
                else:
                    pyautogui.typewrite(word, interval=0)

スクリプトまとめ

#  -*- utf-8 -*-
import pyautogui
import feedparser
import json
import string
import re
from regex_dict import regex_dict


def set_input_words(_input_str, _command_vars_dict):
    words = [word for word in re.findall(r'[a-z]+[0-9]*|[0-9]+', _input_str) if word != '']
    if words:
        for i in range(len(words)):
            _command_vars_dict['w'+str(i+1)] = words[i]


def convert_speech_input(_speech_input):
    vars_dict = {}
    set_input_words(_speech_input, vars_dict)

    converted_command = string.Template(r_command_dict[_speech_input])
    return converted_command.safe_substitute(vars_dict)


with open('type_keys.json', 'r', encoding='utf-8') as ref_json:
    r_command_dict = regex_dict(json.load(ref_json))
    ref_json.close()

url = 'スプレッドシートのURL'

# スプレッドシートの最新セルの行番号を確認
rss = feedparser.parse(url)
last_input_count = rss.entries[-1].title

while True:
    rss = feedparser.parse(url)

    if rss.entries[-1].title != last_input_count:
        last_input_count = rss.entries[-1].title
        # 音声認識結果に含まれるスペースを詰める
        speech_input = rss.entries[-1].description.replace(' ', '')
        print('received: '+speech_input)

        try:
            # Google Homeの半角単語は大文字で始まるため小文字にして変換する
            result = convert_speech_input(speech_input.lower()).split('`')

            reserved_word = ['enter', 'esc']
            for word in result:
                if word in reserved_word:
                    pyautogui.press(word)
                else:
                    pyautogui.typewrite(word, interval=0)

        except KeyError:
            print('Key Error')
            continue

まとめ

音声認識については,音声入力が長文になるほど正確な認識が難しく感じた.そもそも変数は半角で定義されるのが前提なので,「日本語入力から一部を半角で処理できるのか」という問題がある.幸いGoogle Home音声認識はそれなりに優秀で,ある程度は認識するものの,これに長文音声認識問題が加わると致命的だ.そこで簡単なプログラミングに限定し,繰り返しは回数しか指定しないなど工夫が必要だと思った.

スマートスピーカーの魅力の1つはその広範囲でマイクが音を拾えること
簡単な発話で入力する気軽さ…ある意味正しいデバイスへの入力法なのかもしれない?

今回制作したものは

  • 入力から出力までにかかるラグ
  • ルールの少なさ
  • 音声認識誤り

という問題を抱えているので正直実用的なものではないが,意外と単純に思ったものが制作できる時代になったんだ,と感じた良い経験だった.

続き >>
www.rect29.com