仮想私事の原理式

この世はワタクシゴトのからみ合い

Raspberry PiとUSB赤外線リモコンで音声認識リモコン作った

GWだけど新型コロナでステイホーム状態であることを機に、6年前から寝かせてあったラズパイ1BとUSB赤外線リモコンを使って、当時作ろうと思っていた音声認識リモコンをようやく作ったので、工程をメモ。

目指す機能

  • 家にある家電のリモコン(テレビ、エアコン、照明…)の信号をコピーして出せる
  • 勝手に言葉を拾って動作されると困るので、「ラズ夫」と呼び掛けた後の指令のみ実行する。「OK、G●●GLE」的に。

使用機器及び環境

  • Raspberry Pi 1B
    • Raspbian 10(buster)  kernel 4.19.97
    • julias 4.5(音声認識エンジン)
  • USBマイク(メーカ忘れた)
  • USB赤外線リモコンキット AD00020P(BitTradeOne)

USBマイクのテスト

USBマイクをとりあえずラズパイに接続し、認識できているかlsusbで確認します。

# lsusb
Bus 001 Device 005: ID 0d8c:013c C-Media Electronics, Inc. CM108 Audio Controller    # これがUSBマイク
Bus 001 Device 003: ID 0424:ec00 Standard Microsystems Corp. SMSC9512/9514 Fast Ethernet Adapter
Bus 001 Device 002: ID 0424:9514 Standard Microsystems Corp. SMC9514 Hub
Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub

ラズパイとUSBマイクが正常動作しているか、音声を録音し、ファイルに保存してみます。 録音には arecord コマンドを使いますが、arecordコマンドではUSBマイクに割り当てられている「カードNo」と「デバイスNo」が必要なので、「arecord -l」コマンドで確認します。

# arecord -l
**** ハードウェアデバイス CAPTURE のリスト ****
<b>カード 1: Device [USB PnP Sound Device], デバイス 0: USB Audio [USB Audio]</b>
  サブデバイス: 1/1
  サブデバイス #0: subdevice #0

カードNo:1、デバイスNo:0 と認識されていることが分かりました。 ひきつづき、録音テストします。

# arecord -D plughw:1,0 /tmp/test.wav
録音中 WAVE '/tmp/test.wav' : Unsigned 8 bit, レート 8000 Hz, モノラル
^Cシグナル 割り込み で中断...     # Ctrl + C で録音終了
arecord: pcm_read:2145: 読込エラー: システムコール割り込み

音声ファイル /tmp/test.wav が作成されていれば成功です。たぶん。

音声認識パッケージ 「Julius」のインストールと設定

juliusオープンソース音声認識エンジンです。今回の肝です。

■ juliusをインストール

適当なディレクトリにソースコードをダウンロードし、コンパイル・インストールします。 事前に「osspd-alsa」「libasound2-dev」をインストールしていますが、これはRaspbianのLinuxカーネルバージョンが4.3以上の場合のようです。

# mkdir /opt/julius
# cd /opt/julius
# apt install osspd-alsa    # ALSAはLinux用サウンドドライバ
# apt install libasound2-dev
# git clone https://github.com/julius-speech/julius.git    # ソースコードをダウンロード
# ./configure --with-mictype=alsa
# make
# make install

■ juliusのディクテーションキットの取得

juliusは、単語パターンに音声を当てはめることによって、単語として認識しているようです。日本語向けのディクテーションキットを使うことで日本語の単語を認識させられるようになります。 今回は一般的なディクテーションキットを使用しましたが、話し言葉向けなどのパターンもあるようです。

# wget https://osdn.net/dl/julius/dictation-kit-4.5.zip
# unzip dictation-kit-4.5.zip 

■ ALSADEV設定で、使用するデバイスを設定

「arecord -l」で確認したUSBマイクのカードNo とデバイスNo を、ALSAバイスとして使うように設定します。 ALSAは Advanced Linux Sound Architecture(Linux用のサウンドドライバ)です。

# echo "export ALSADEV=plughw:1,0" >> /etc/profile
# source /etc/profile

■ juliusの動作確認

ここらでいったん、juliusがちゃんと音声認識できているかを確認します。

# julius  -C /opt/julius/dictation-kit-4.5/main.jconf  -C /opt/julius/dictation-kit-4.5/am-gmm.jconf  -demo
   ↓
<<< please speak >>>     # このように表示されれば起動完了

ラズパイ1Bのスペックが低いせいか、処理がかなり遅いですが、とりあえず音声認識できているようです。ただ、誤認識の割合が非常に高いです。

独自辞書を作成

ディクテーションキットを素で使うと、多くの言葉を認識させられますが処理が遅くなります。そこで、独自辞書を使用して認識できる言葉を減らす代わりに認識速度を上げることにします。

なお、以下では省略しますが、本来必要とはしない「単語」もいくつか登録しておくと良いです。 独自辞書では、当てはめるパターンが少ないので認識スピードが上がります。ただ、どうやらjuliusは無理やりにでも何かのパターンに当てはめようとするので、「ビデオ」といっても「ラズ夫」に当てはめてしまう可能性があります。 今回、「ラズ夫」と呼び掛けた後の指令を実行するようにしたいので、なんでもかんでも「ラズ夫」と認識されると困ります。そこで、適当な単語(「バナナ」とか「ヤマザキ」とか)も登録しておきます。そうすることで、「ラズ夫」と誤認識される確率を減らすわけです。 テレビ見ながら作業してたので、そのときテレビから聞こえてくる単語を適当に入力しました。

■ 読みファイル(.yomi)作成

# mkdir mydict
# cd mydict
# vim mydict.yomi
ラズ夫 らずお    # 左列は表示文字なので漢字もOK。右列は平仮名のみ(読み)
テレビ てれび
点けて つけて
消して けして
(以下略)

■ 音素ファイル(.phone)へのコンパイル

# iconv -t utf8 -f utf8 mydict.yomi  |  \
/opt/julius/gramtools/yomi2voca/yomi2voca.pl  | \
 iconv -f utf8 -t utf8 > mydict.phone
# cat mydict.phne
ラズ夫   r a z u o
テレビ   t e r e b i
点けて   ts u k e t e
消して   k e sh i t e
(以下略)

■ 構文ファイル(.grammar)作成

# vim mydict.grammar
S : NS_B MYNAME NS_E
MYNAME : RAZURO

S : NS_B TARGET ACTION NS_E
TARGET : TEREBI
ACTION : TSUKETE
ACTION : KESHITE
(以下略)

「S」は文章を表し、「NS_B」が開始、「NS_E」が終了。「S : NS_B TARGET ACTION NS_E」として「TARGET」「ACTION」の候補を後に列挙しておくと、それらを当てはめて認識します。Sは複数定義可能です。

■ 語彙ファイルの作成

# vim mydict.voca 
% RAZURO
ラズ夫      r a z u o
% RAITO
テレビ      t e r e b i
% TSUKETE
点けて      ts u k e t e
%KESHITE
消して      k e sh i t e
% NS_B
[s]         silB
% NS_B
[/s]        silE

語彙ファイルは音素ファイルを元に作れば楽そうですが、音素ファイルの区切り文字がタブなのに対して、語彙ファイルの区切り文字は空白(スペース)でなければいけないので、置換する必要があります。

オートマトンと単語辞書へのコンパイル

# mkdfa.pl  /opt/julius/mydict/mydict      # 拡張子は指定しません

■ 独自辞書でのjuliusの起動

# julius -C /opt/julius/dictation-kit-4.5/am-gmm.jconf -nostrip -gram /opt/julius/mydict/mydict -demo

-gram オプションで、辞書のベースファイル名を指定します。

juliusをmoduleモードで実行

juliusによる音声認識はできましたが、このままでは「認識しただけ」です。認識結果を元に他のプログラムと連動させるためには、juliusを音声認識サーバとして動作させる「moduleモード」にする必要があります。

moduleモードで動かすには「-demo」としていたオプションを「-module」とするだけです。

# julius -C /opt/julius/dictation-kit-4.5/am-gmm.jconf -nostrip -nolog  -gram /opt/julius/mydict/mydict -module

moduleモードで起動すると、デフォルトで10500/tcpのポートが開きます。当該ポートにクライアントから接続すると、juliusは音声認識可能な状態となり、接続しているクライアントに対して音声入力の開始、内容、終了などのイベントを随時送出するようになります。 クライアントへ送られるメッセージはXML形式です。改行コードは"\n"。一回のメッセージ送信ごとに、データ終端として" . "(ピリオド)のみの行が送信されます。以下はメッセージの例です。

<STARTPROC/>
<INPUT STATUS="LISTEN" TIME="994675053"/>
<INPUT STATUS="STARTREC" TIME="994675055"/>
<STARTRECOG/>
<INPUT STATUS="ENDREC" TIME="994675059"/>
<GMM RESULT="adult" CMSCORE="1.000000"/>
<ENDRECOG/>
<INPUTPARAM FRAMES="382" MSEC="3820"/>
<RECOGOUT>        # 認識失敗時は<RECOGFAIL>が出力される。
  <SHYPO RANK="1" SCORE="-6888.637695" GRAM="0">
    <WHYPO WORD="silB" CLASSID="39" PHONE="silB" CM="1.000"/>
    <WHYPO WORD="上着" CLASSID="0" PHONE="u w a g i" CM="1.000"/>
    <WHYPO WORD="" CLASSID="35" PHONE="o" CM="1.000"/>
    <WHYPO WORD="" CLASSID="2" PHONE="sh i r o" CM="0.988"/>
    <WHYPO WORD="" CLASSID="37" PHONE="n i" CM="1.000"/>
    <WHYPO WORD="して" CLASSID="27" PHONE="sh i t e" CM="1.000"/>
    <WHYPO WORD="下さい" CLASSID="28" PHONE="k u d a s a i" CM="1.000"/>
    <WHYPO WORD="silE" CLASSID="40" PHONE="silE" CM="1.000"/>
  </SHYPO>
</RECOGOUT>
.        # ← データ終端のピリオド

USB赤外線リモコンの作成

BitTradeOne製 USB赤外線リモコンキット AD00020P f:id:FEMRIK:20200503212851j:plain

秋月で見つけてなんとなく買ってしまったものですが、ネット上ではこの上位版である USB赤外線リモコンADVANCE(ADIR01P) を使っている方が多かったです。 しかも、これは組み立てるときに気付きましたが、Windows向けなんですよね…。有志の方がLinux版のコマンドを作ってくれていて助かりました。githubにあったので、ソースコードをもらってコンパイルします。

# git clone https://github.com/kjmkznr/bto_ir_cmd
# cd bto_ir_cmd/
# make
# ./bto_ir_cmd -h
usage: bto_ir_cmd <option>
  -r        Receive Infrared code.
  -t <code>   Transfer Infrared code.
  -e        Extend Infrared code.
              Normal:  7 octets
              Extend: 35 octets
# ./bto_ir_cmd -r -e
Extend mode on.
Receive mod   # この表示が出たらUSB赤外線リモコンに向けて覚えさせたいボタンを押す
  Received code: 014098022(…中略…)C0000000000    # 受信した赤外線コード
# echo 014098022(…中略…)C0000000000 > ircode.dat    # 赤外線コードをファイルに保存
# cat ircode.dat | xargs -I {} ./bto_ir_cmd -e -t {}    # ファイルからコードを読み出して送信

無事にラズパイでも使用できました。

音声認識と赤外線リモコンの連動

音声命令を受信したら、赤外線リモコンコマンド(bto_ir_cmd)を実行するプログラムを、Python3で書きます。

#!/usr/bin/env python3

import socket
import subprocess
import time

julius_host = '127.0.0.1'
julius_port = 10500
has_called = False

def process_query(sentence):
    global has_called

    if has_called == False :
        if "ラズオ" in sentence:
            print("ご用ですか?")
            has_called = True

    elif has_called == True :
        if "テレビ点けて" in sentence :
            cmd = "cat /opt/bto_ir_cmd/tv_power.ircode | xargs -I {} /opt/bto_ir_cmd/bto_ir_cmd -e -t {}"
            result = subprocess.call(cmd,shell=True)
            has_called = False

        elif "テレビ消して" in sentence :
            cmd = "cat /opt/bto_ir_cmd/tv_power.ircode | xargs -I {} /opt/bto_ir_cmd/bto_ir_cmd -e -t {}"
            subprocess.call(cmd,shell=True)
            has_called = False

        elif "エアコン点けて" in sentence :
            cmd = "cat /opt/bto_ir_cmd/aircon_poweron.ircode | xargs -I {} /opt/bto_ir_cmd/bto_ir_cmd -e -t {}"
            subprocess.call(cmd,shell=True)
            has_called = False
        
        elif "エアコン消して" in sentence :
            cmd = "cat /opt/bto_ir_cmd/aircon_poweroff.ircode | xargs -I {} /opt/bto_ir_cmd/bto_ir_cmd -e -t {}"
            subprocess.call(cmd,shell=True)
            has_called = False

        elif "バイバイ" in sentence:
            print("はい、おやすみなさい(-_-)ノシ")
            return False

        elif "ラズオ" in sentence:
            print("だから、なんですか?")

        else :
            print("え、なに?")
            has_called = False

    else:
        print("(;Д;)")
    return True

def main():
    # Start Julius
    julius_process = subprocess.Popen(["/opt/julius/julius_module_start.sh"], stdout=subprocess.PIPE, shell=True)
    # Get julius PID
    julius_pid = str(julius_process.stdout.read().decode('utf-8'))

    client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    client.connect((julius_host,julius_port))
    print("<<< please speak! (-_-)3 >>>")

    try:
        data = ''
        sentence = ''
        loop = True
        while loop:
            if '</RECOGOUT>\n.' in data:
                word = ""
                for line in data.split('\n'):
                    index = line.find('WORD="')
                    if index != -1:
                        line = line[index+6:line.find('"', index+6)]
                        word = str(line)
                    
                    if (word != '[s]') and (word != '[/s]') :
                        sentence += word
                
                print('"' + sentence + '"')
                loop = process_query(sentence)
                
                sentence = ''
                data = ''
                print("<<< please speak! (-_-)3 >>>")
            
            else:
                data += str(client.recv(1024).decode('utf-8'))

        # Kill Process
        julius_process.kill()
        subprocess.call(["kill "+julius_pid], shell=True)
        client.close()
 
    except KeyboardInterrupt:
        #Close Client (Julius sever is active yet)
        # Kill julius
        julius_process.kill()
        subprocess.call(["kill "+julius_pid], shell=True)
        client.close()
        print(" Julius process finished.")

if __name__ == "__main__":
    main()

たまに起動に失敗するけど、結構いい感じで動きました! ただ、音声の聞き取り距離が短いのと、USB赤外線リモコンの到達範囲も遠い。セルフパワーのUSBハブを経由した方がいいみたいです。 動画をUPできなかったので、Twitterから↓。

音声リモコン(スマートリモコン)作っている方は既に沢山いて今更な上、ラズパイ4が出ているご時勢にラズパイ1ですが、はじめから終わりまで紹介しているところは少ないように思ったので、これはこれで役に立てばいいなと。 エンジョイ ホーム!

参考