SNIPS ネットにつながない音声認識

スポンサーリンク

2019年にSNIPSはSonosという会社に買収され、本記事の内容は実施できなくなっています。

過去にこういう環境があったという参考として記事は残してあります。

無料かつオフラインでの音声認識Juliusを以下の記事で紹介していますので、ご興味をお持ちの方は御覧ください。
Raspberry Pi+Juliusで音声認識

はじめに

ネットにつながず、ラズパイなどのエッジデバイスで音声認識ができるSNIPSを知り、試してみました。ハマりやすいポイントもありましたので、メモとして残します。

概要

ラズパイに搭載したSNIPSに音声認識をさせて外部機器を動かせたら様々ものに利用できると考え、試してみました。
今回作成したのは声で操作する計算機で、SNIPSの起動ワード「Hey SNIPS!」に続き、「2足す3は?」と尋ねると「5」と答えてくれて、「5」というデータをSPI接続したGR-CITRUSに送ってデジタル表示器で「5」を表示してくれるというものです。

使用したもの

最低限、ラズパイとマイク、スピーカがあればSNIPSを試せますが、今回はラズパイで音声認識させて何かを動かすことを目的としたので、動かす対象としてがじぇるねGR-CITRUSを使用しました。

  • Raspberry Pi 3 Model B
  • USB MINI SPEAKER(ダイソーで300円)
  • ミニUSBマイク(秋月で380円)
  • GR-CITRUS
  • 7セグメント表示器 A-551SRD(秋月で40円)
  • 150Ω抵抗 × 8個

ラズパイはPi3が推奨されています。Pi Zeroは2018年6月現在、動作しませんでしたのでご注意ください。当初、Pi Zeroでトライしていましたがうまく行かず、SNIPSコミュニティに質問して、Pi ZeroのARMv6アーキテクチャには未対応であることがわかりました。

以下が回路図。

ラズパイの準備

SNIPSの推奨OSはコマンドライン環境のRaspbian Liteとなっているので、Raspbianのダウンロードサイトから”RASPBIAN STRETCH LITE”をダウンロードします。2018年6月末現在の最新は以下でした。
2018-06-27-raspbian-stretch-lite.zip
上記zipファイルを7zip等で展開すると以下のDISKイメージファイルができます。
2018-06-27-raspbian-stretch-lite.img

次にSDカードをSD Formatter等でフォーマットします。このときオプション設定がデフォルトの「クイックフォーマット」ではうまく行かない場合があるようなので、「上書きフォーマット」を選択してフォーマットします。

SDカードのフォーマットが終わったら、一度SDカードを抜いて差し直した方が良いようです。
最初にダウンロードしたRaspbianのDISKイメージをWin32 Disk Imager等を使用してSDカードに書き込みます。オプションはデフォルト状態で問題ありませんでした。

その後、「boot」という名前になっているSDカードのドライブの直下(config.txtなどが見える場所)に拡張子無し「ssh」という名称の空ファイルを置きます。これはラズパイをWifiにつないでパソコンからssh接続する際にこの処置が必要になります。ssh接続しない場合は不要です。
メモ帳などでsshという空ファイルを作った場合は「ssh.txt」となるので、ファイル名を編集して「ssh」に直します。
エクスプローラ等でSDカードにアクセスする際、「ドライブF:をフォーマットする必要があります」のようなポップアップが出ても無視してキャンセルします。

SDカードをラズパイに差し込み、キーボードとディスプレイをつないでラズパイの電源を入れ、デフォルトIDのpi(PW: raspberry)でログインし、パスワードの変更やLocale、Wifi等の設定をします。設定が終わったら再起動します。

$ sudo raspi-config
$ sudo reboot

TeraTerm等を使ってssh接続する場合は、ifconfigでIPアドレスを確認してシャットダウンします。キーボードやディスプレイが不要な場合は外します。(HDMIディスプレイから音を出す場合はディスプレイは接続しておきます。)

$ ifconfig
$ sudo shutdown -h now

ラズパイに直接ログインすると画面は1つですが、ssh接続なら複数画面を立ち上げられるのでお勧めです。

SNIPSのインストール

ラズパイ向けインストールのページの「Step 3: Install the Snips Platform」から実施します。(Step1~2は上記で設定が完了しているため)

$ sudo apt-get update
$ sudo apt-get upgrade
$ sudo apt-get install -y dirmngr
$ sudo bash -c  'echo "deb https://raspbian.snips.ai/$(lsb_release -cs) stable main" > /etc/apt/sources.list.d/snips.list'
$ sudo apt-key adv --keyserver pgp.mit.edu --recv-keys D4F50CDCA10A2849
$ sudo apt-get update
$ sudo apt-get install -y snips-platform-voice

次に以下のコマンドでスピーカとマイクのcard番号とデバイス番号を確認し、viやnano等のテキストエディタで/etc/asound.confに設定します。

$ aplay -l
$ arecord -l
$ sudo vi /etc/asound.conf

私の場合は例と同じだったので/etc/asound.confの中身は以下にしました。

pcm.!default {
  type asym
   playback.pcm {
     type plug
     slave.pcm "hw:0,0"
   }
   capture.pcm {
     type plug
     slave.pcm "hw:1,0"
   }
}

ここでスピーカとマイクのテストをします。このときsnips-audio-serverを一時的に止める必要があります。

$ sudo systemctl stop snips-audio-server
$ arecord -f cd out.wav

ここで何かしゃべってCtrl+Cで止めます。

$ aplay out.wav

これで録音した音声が正常に聞ければOKです。忘れずにsnips-audio-serverを開始しておきます。

$ sudo systemctl start snips-audio-server

alsamixerでオーディオデバイスの設定を変えることができます。私の場合はスピーカの音量が小さかったので最大にしました。

$ alsamixer

続いてsnips-watch、npm、nodejs、snips-samをインストールしていきます。

$ sudo apt-get install snips-watch
$ sudo apt-get update
$ sudo apt-get upgrade
$ sudo apt-get install -y nodejs npm
$ sudo npm cache clean
$ sudo npm install npm n -g
$ sudo n stable
$ sudo npm install -g snips-sam

途中、以下のようなWARNINGメッセージが出ましたが、問題ないようです。

/usr/local/bin/n -> /usr/local/lib/node_modules/n/bin/n
npm WARN package.json path-is-inside@1.0.2 No README data
npm WARN package.json sorted-object@2.0.1 No README data
/usr/local/bin/npm -> /usr/local/lib/node_modules/npm/bin/npm-cli.js
/usr/local/bin/npx -> /usr/local/lib/node_modules/npm/bin/npx-cli.js
npm WARN package.json config-chain@1.1.11 No license field.
npm WARN package.json qrcode-terminal@0.12.0 No license field.
n@2.1.12 /usr/local/lib/node_modules/n

npm@6.1.0 /usr/local/lib/node_modules/npm

音声解釈機能の作成

Web上のSNIPSの開発ページから音声解釈機能を作成していきます。初めての場合、Emailとパスワードを登録してアカウントを作ります。
ログインしたら「Create a New Assistant」を押し、アシスタント名と使用言語を選択してアシスタントを作成します。この例では日本語を選択しています。(2018年6月現在、日本語の解釈のみ対応しており、日本語の発声はできません。)
続いて「Add a skill」→「Create a New Skill」でSkill名と説明を書いて「Create」を押します。
今作ったSkillをクリックして「Edit Skill」を押します。
「Create a New Intent」を押してIntentを作成します。
Intentの名前と説明を書いておきます。
以下は足し算をするSkillのSlotsとTraining Exampleの例です。

Slotsには数値や物の名称など、切り出してプログラムに渡して処理を行いたいものを定義します。足し算の場合、足される数と足す数がSlotになります。
今回は2つのSlotを数値として取り出したいため、TYPEはsnips/numberを選択します。
Training Exampleには想定する入力フレーズを書き、Slotに相当する部分を選択して右クリックでSlotを割り当てます。
右の方にテスト用のツールがあり、音声入力に対する解釈をJSON形式のデータとして見ることができるので、青いマイクのボタンを押して話しかけるか、文字列入力をして正しくSlotに値が入るか確認します。
入力フレーズ内の数値は1のみで表現していますが、Slotにすることで他の数値でも認識してくれることがわかります。
問題なければ「Save」を押します。
上記は足し算の例ですが、同様に引き算、掛け算、割り算も作成しました。

次にActionを作成します。画面上部に
Home > アシスタント名 > Skill名 > Intent名
と表示されている箇所のSkill名(私の例ではCalculator)をクリックし、「Actions」を押します。
Action Typeとして4つの選択肢がありますが、ここでは自由にPythonプログラムを書ける「Code Snippets」について説明します。
デフォルトではコメントのみ記入されているテキスト入力欄に以下のようにPythonプログラムを書き込みます。
以下は割り算の例です。

#import RPi.GPIO as GPIO
import serial
from time import sleep

#GPIO.setwarnings(False)
#GPIO.setmode(GPIO.BOARD)
#GPIO.setup(12, GPIO.OUT)
ser = serial.Serial("/dev/ttyACM0", baudrate = 9600, timeout = 2)

ans = 0
if len(intentMessage.slots.first_number) > 0 and len(intentMessage.slots.second_number) > 0:
    ans = intentMessage.slots.first_number.first().value / intentMessage.slots.second_number.first().value
    if ans == int(ans):
        ans = int(ans);
    print("ans:", ans)
    result_sentence = "The answer is {}.".format(str(ans))
    print("say:", result_sentence)
    ser.write(str(ans))
    #if ans > 9:
    #    ans = 9
    #for i in range(ans):
    #    GPIO.output(12, GPIO.HIGH)
    #    sleep(0.15)
    #    GPIO.output(12, GPIO.LOW)
    #    sleep(0.15)
else:
    result_sentence = "Sorry, I didn\'t understand."
    ser.write("E")

current_session_id = intentMessage.session_id
hermes.publish_end_session(current_session_id, result_sentence)

Slotとして定義したfirst_numberとsecond_numberが空でなければ割り算をして答えをansに格納し、SNIPSに話させるための文字列result_sentenceを作り、hermes.publish_end_session()に渡します。
ansは割り切れて整数になったときでも「5.0」のように少数となるのが嫌だったのでint()で整数にしています。
#から始まるコメント部分はGPIOを動かしてLEDを点灯させる場合のメモです。
うまくSlotを取り出せなかった場合は、聞き取れなかったというコメントと共にシリアルポートにはEの文字を返すようにしています。
2018年6月現在、日本語での発話はできないため、話す内容は英語で書いています。
なお、ここでは書き込んだCode Snippetのデバッグはできず、またPythonのフォーマットとして不正であっても何もチェックしてくれません。デバッグはラズパイにインポートしてから行うことになります。

音声解釈機能のインストール

上記で作成した音声解釈機能をsamというSNIPSを操作するための便利ツールを使ってラズパイにインストール(Deploy)します。

$ sam login

ここでSNIPS開発ページのログインEmailとパスワードを使います。次にsamをラズパイに接続します。マシン名は特に変更していなければ、raspberrypiになっているはずで、それに.localを付けて以下のようにします。

$ sam connect raspberrypi.local

環境によってはraspberrypi.localの部分をIPアドレスにしないとつながらないかもしれません。その場合はifconfigでラズパイのIPアドレスを調べて使用します。
いよいよAssistantをインストールします。

$ sam install assistant
$ sam install actions

複数Assistantを作成した場合はここで選択可能な状態になりますので、矢印キーでインストールしたいAssitantを選びEnterを押します。

SNIPSからGPIOやSerial通信を利用するために以下の手順でvenv上のPythonにrpi.gpio、pyserialをインストールする必要があります。

$ sudo usermod -a -G spi,gpio _snips-skills
$ source /var/lib/snips/skills/ユーザ名.Skill名/venv/bin/activate
$ pip install rpi.gpio
$ pip install pyserial
$ deactivate

actionは_snips-skillsユーザで実行されるため、シリアルポートにアクセスできる権限を付けます。

$ sudo usermod -a -G dialout _snips-skills

シリアルポートを有効にします。以下はssh通信でラズパイを操作し、ログインにシリアルポートを使用せず、GR-CITRUSとの通信として使用する場合の設定を示します。

$ sudo raspi-config

さて、actionのインストール後は/var/lib/snips/skills/ユーザ名.Skill名/action-*のファイル内action_wrapperメソッドにCode Snippetが入っているのがわかります。
なお、SNIPSのコミュニティによると、一旦ラズパイにインストールした後は、Web上のCode Snippetではなく、このファイルを直に修正するのがお勧めとのこと。Web上のCode Snippetではデバッグもできないため、初期環境立ち上げの際に必要となる仮プログラムとして捉えれば良いようです。
Actionを直に修正したら以下のようにsnips-skill-serverを再起動します。以下のようにsnips-skill-serverのログを表示させるとprint等で表示した内容も確認でき、デバッグに役立ちます。

$ sam service restart snips-skill-server
$ sam service log snips-skill-server

また、以下sam watchも音声がどのように解釈されたかを確認することができるので、上記ログと別のWindowを立ち上げて実行しておくのがお勧めです。

$ sam watch

ここまで来たら、いよいよラズパイにつなげたマイクに向かって話しかけます。「Hey SNIPS!」、「3たす4は?」のように話しかけてみてください。「Hey SNIPS!」がうまく認識されると「ピコーン」という音が鳴ります。
うまくいかない場合は、snips-skill-serverのログやsam watchの内容を確認します。
sam statusも状況を知るのに役立ちます。問題がなければ以下のように表示されます。

$ sam status

Connected to device raspberrypi.local

OS version ................... Raspbian GNU/Linux 9 (stretch)
Installed assistant .......... JP_SNIPS
Language ..................... ja
Hotword ...................... hey_snips
ASR engine ................... snips
Status ....................... Live

Service status:

snips-analytics .............. 0.56.4 (running)
snips-asr .................... 0.56.4 (running)
snips-audio-server ........... 0.56.4 (running)
snips-dialogue ............... 0.56.4 (running)
snips-hotword ................ 0.56.4 (running)
snips-nlu .................... 0.56.4 (running)
snips-skill-server ........... 0.56.4 (running)
snips-tts .................... 0.56.4 (running)

また、/var/log/syslogにも有用な情報が出力されます。

$ tail -f /var/log/syslog

Actionをインストールし忘れたり、Actionの内容に問題があったりするとsnips-skill-serverが停止と開始を繰り返すことがあり、そのような状況はこのファイルで確認できます。

GR-CITRUSのプログラミング

ラズパイからシリアル通信で送信した数値を表す文字列をGR-CITRUSで受け取り、7セグメント表示器にデジタル表示するプログラム例です。
マイナスや小数点、エラー時のEを表示できるようにしました。
電源投入直後はtest_digit()で全文字列を順に表示します。入力がないときは∞の字をなぞるような動きをして入力待ちであることを表現しています。
これで概要にある動画のように答えを英語で話してくれると共にデジタル表示されるようになります。

const int WAIT         = 500;
const int CLEAR_WAIT   = 50;
const int MOTION_WAIT  = 100;
const int leftBottom   = 9;
const int bottom       = 8;
const int rightBottom  = 7;
const int dot          = 6;
const int center       = 10;
const int leftTop      = 11;
const int top          = 12;
const int rightTop     = 13;
char inByte            = '.';
int motionCnt          = 0;
int motionPtn[9]       = {8, 7, 10, 11, 12, 13, 10, 9, 8};

void setup() {
  Serial.begin(9600);
  pinMode(leftBottom,  OUTPUT);
  pinMode(bottom,      OUTPUT);
  pinMode(rightBottom, OUTPUT);
  pinMode(dot,         OUTPUT);
  pinMode(center,      OUTPUT);
  pinMode(leftTop,     OUTPUT);
  pinMode(top,         OUTPUT);
  pinMode(rightTop,    OUTPUT);
  test_digit();
  clear_digit();
}

void loop() {
  if (Serial.available() > 0) {
    inByte = Serial.read();
    Serial.write(inByte);
    Serial.write('\n');
    digit(inByte);
    motionCnt = -1;
  }else{
    motion();
  }
}

void motion(){
  if(motionCnt == -1){
    delay(WAIT * 5);
    motionCnt = 0;
  }
  clear_digit();
  digitalWrite(motionPtn[motionCnt], LOW);
  digitalWrite(motionPtn[motionCnt + 1], LOW);
  delay(MOTION_WAIT);
  motionCnt++;
  if(motionCnt == 8) motionCnt = 0;
}

void clear_digit() {
  digitalWrite(leftBottom,  HIGH);
  digitalWrite(bottom,      HIGH);
  digitalWrite(rightBottom, HIGH);
  digitalWrite(dot,         HIGH);
  digitalWrite(center,      HIGH);
  digitalWrite(leftTop,     HIGH);
  digitalWrite(top,         HIGH);
  digitalWrite(rightTop,    HIGH);
}

void digit(char num) {
  clear_digit();
  delay(CLEAR_WAIT);
  if(num == '0'){
    digitalWrite(leftBottom,  LOW);
    digitalWrite(bottom,      LOW);
    digitalWrite(rightBottom, LOW);
    digitalWrite(dot,         HIGH);
    digitalWrite(center,      HIGH);
    digitalWrite(leftTop,     LOW);
    digitalWrite(top,         LOW);
    digitalWrite(rightTop,    LOW);
  }else if(num == '1'){
    digitalWrite(leftBottom,  HIGH);
    digitalWrite(bottom,      HIGH);
    digitalWrite(rightBottom, LOW);
    digitalWrite(dot,         HIGH);
    digitalWrite(center,      HIGH);
    digitalWrite(leftTop,     HIGH);
    digitalWrite(top,         HIGH);
    digitalWrite(rightTop,    LOW);
  }else if(num == '2'){
    digitalWrite(leftBottom,  LOW);
    digitalWrite(bottom,      LOW);
    digitalWrite(rightBottom, HIGH);
    digitalWrite(dot,         HIGH);
    digitalWrite(center,      LOW);
    digitalWrite(leftTop,     HIGH);
    digitalWrite(top,         LOW);
    digitalWrite(rightTop,    LOW);
  }else if(num == '3'){
    digitalWrite(leftBottom,  HIGH);
    digitalWrite(bottom,      LOW);
    digitalWrite(rightBottom, LOW);
    digitalWrite(dot,         HIGH);
    digitalWrite(center,      LOW);
    digitalWrite(leftTop,     HIGH);
    digitalWrite(top,         LOW);
    digitalWrite(rightTop,    LOW);
  }else if(num == '4'){
    digitalWrite(leftBottom,  HIGH);
    digitalWrite(bottom,      HIGH);
    digitalWrite(rightBottom, LOW);
    digitalWrite(dot,         HIGH);
    digitalWrite(center,      LOW);
    digitalWrite(leftTop,     LOW);
    digitalWrite(top,         HIGH);
    digitalWrite(rightTop,    LOW);
  }else if(num == '5'){
    digitalWrite(leftBottom,  HIGH);
    digitalWrite(bottom,      LOW);
    digitalWrite(rightBottom, LOW);
    digitalWrite(dot,         HIGH);
    digitalWrite(center,      LOW);
    digitalWrite(leftTop,     LOW);
    digitalWrite(top,         LOW);
    digitalWrite(rightTop,    HIGH);
  }else if(num == '6'){
    digitalWrite(leftBottom,  LOW);
    digitalWrite(bottom,      LOW);
    digitalWrite(rightBottom, LOW);
    digitalWrite(dot,         HIGH);
    digitalWrite(center,      LOW);
    digitalWrite(leftTop,     LOW);
    digitalWrite(top,         LOW);
    digitalWrite(rightTop,    HIGH);
  }else if(num == '7'){
    digitalWrite(leftBottom,  HIGH);
    digitalWrite(bottom,      HIGH);
    digitalWrite(rightBottom, LOW);
    digitalWrite(dot,         HIGH);
    digitalWrite(center,      HIGH);
    digitalWrite(leftTop,     LOW);
    digitalWrite(top,         LOW);
    digitalWrite(rightTop,    LOW);
  }else if(num == '8'){
    digitalWrite(leftBottom,  LOW);
    digitalWrite(bottom,      LOW);
    digitalWrite(rightBottom, LOW);
    digitalWrite(dot,         HIGH);
    digitalWrite(center,      LOW);
    digitalWrite(leftTop,     LOW);
    digitalWrite(top,         LOW);
    digitalWrite(rightTop,    LOW);
  }else if(num == '9'){
    digitalWrite(leftBottom,  HIGH);
    digitalWrite(bottom,      LOW);
    digitalWrite(rightBottom, LOW);
    digitalWrite(dot,         HIGH);
    digitalWrite(center,      LOW);
    digitalWrite(leftTop,     LOW);
    digitalWrite(top,         LOW);
    digitalWrite(rightTop,    LOW);
  }else if(num == '-'){
    digitalWrite(leftBottom,  HIGH);
    digitalWrite(bottom,      HIGH);
    digitalWrite(rightBottom, HIGH);
    digitalWrite(dot,         HIGH);
    digitalWrite(center,      LOW);
    digitalWrite(leftTop,     HIGH);
    digitalWrite(top,         HIGH);
    digitalWrite(rightTop,    HIGH);
  }else if(num == '.'){
    digitalWrite(leftBottom,  HIGH);
    digitalWrite(bottom,      HIGH);
    digitalWrite(rightBottom, HIGH);
    digitalWrite(dot,         LOW);
    digitalWrite(center,      HIGH);
    digitalWrite(leftTop,     HIGH);
    digitalWrite(top,         HIGH);
    digitalWrite(rightTop,    HIGH);
  }else if(num == 'E'){
    digitalWrite(leftBottom,  LOW);
    digitalWrite(bottom,      LOW);
    digitalWrite(rightBottom, HIGH);
    digitalWrite(dot,         LOW);
    digitalWrite(center,      LOW);
    digitalWrite(leftTop,     LOW);
    digitalWrite(top,         LOW);
    digitalWrite(rightTop,    HIGH);
  }else{
    clear_digit();
  }
  delay(WAIT);
}

void test_digit() {
  digit('0');
  digit('1');
  digit('2');
  digit('3');
  digit('4');
  digit('5');
  digit('6');
  digit('7');
  digit('8');
  digit('9');
  digit('-');
  digit('.');
  digit('E');
}

おわりに

ここまでの機能を実現するのに何度もハマりましたが、その度、SNIPSのコミュニティで質問すると真摯に回答してくれて解決することができ、感謝しています。大変有用なコミュニティなのでSNIPSで何か問題が起きたら利用することをお勧めします。

なお、他の無料で使える音声認識ツールJuliusについても試しましたので参考にしてください。
Raspberry Pi+Juliusで音声認識

参考文献

タイトルとURLをコピーしました