Raspberry Pi+Juliusでオフライン音声認識(C言語)

スポンサーリンク

京都大学で開発された無料の音声認識ツールJuliusRaspberry Piで試しました。

スマートスピーカは、”OK Google”のようなウェイクアップキーワードのみ末端機器で認識し、それに続く音声はクラウド側で処理するという仕組みが主流ですが、音声データはプライベート情報を含む場合があるため、クラウドに送らず手元の機器のみで処理可能ならそれに越したことはないと思います。

クラウドに音声を送らずに音声認識するツールとして以前の記事で紹介したSNIPSがありますが、最近マルチプラットフォームのJuliusというツールがあることを知りました。SNIPSがPythonで書かれているのに対し、JuliusはC言語で書かれています。

音声で操作する機器は常に音声による指示待ちをしているという特性上、パソコンのように日々電源を落とすような機器よりは小型・低消費電力・常時ONの機器で使用したい場合が多いと思います。

Pythonが動く小型の機器というだけで選択肢が狭まりますが、C言語であれば多くの機器が対象に入ってくるため、可能性を感じます。

他のサイトでも紹介されているようにRaspberry PiでJuliusを試したのですが、それなりにつまづいたので備忘録として本記事を残します。

動作環境

構成要素内容補足
Juliusrev.4.52020/3/10時点最新
Raspberry Pi本体Raspberry Pi3B, Raspberry Pi4B動作確認した機器
Raspberry Pi OSRaspbian Buster with desktop2020/3/10時点最新
USBマイクUSBマイク音声入力用、型式不明
性能の良いマイクを使えば認識精度も上がると思われます。
スピーカースピーカー音声出力用、型式不明
USBから電源を取るタイプ。本ページの試行において、スピーカは音声入力が正しくできているかの確認に使用しているだけです。

Raspberry Piの初期設定についてはこちらを参考にしてください。

音声関連の準備

これ以降はRaspberry Pi上の操作になります。

サウンド関連、プログラム開発関連、Gitでバイナリファイルを扱うためのgit-lfs等のプログラムをインストールします。

$ sudo apt-get install git-lfs alsa-utils sox libsox-fmt-all osspd-alsa libasound2-dev libesd0-dev libsndfile1-dev

続いて、このサイトを参考にヘッドフォン端子から音声出力を試します。

以下のコマンドで音声出力をヘッドフォン端子(アナログ出力)に固定します。

$ amixer cset numid=3 1

ヘッドフォン端子にヘッドフォンやスピーカをつなげ、以下のコマンドでサインカーブの音を出してみます。

$ speaker-test -t sine -f 1000

ピーという音が鳴れば成功です。Ctrl+Cで止めます。

音が小さいまたは大きい場合は、以下のコマンドで調整します。

$ alsamixer

↑↓矢印キーで音量を調整し、ESCキーで終了します。

続いて、音声入力を試します。

USBマイクを差した状態で以下のファイルの中身を表示(cat)すると次のように表示されると思います。

$ sudo cat /proc/asound/modules 
 0 snd_bcm2835
 1 snd_usb_audio

snd_usb_audioがUSBマイクでこれの優先順位を上げるために以下のファイルを作成します。

テキストファイルの編集はviを使うことを前提に説明しますが、viの操作に不慣れな方は↓の動画で最低限のテキスト編集の方法を紹介していますので参考にしてください。

$ sudo vi /etc/modprobe.d/alsa-base.conf

以下がviで編集したファイルの中身です。

options snd slots=snd_usb_audio,snd_bcm2835
options snd_usb_audio index=0
options snd_bcm2835 index=1

再起動します。

$ sudo reboot -h now

Raspberry Piとの接続が切れるので、再度SSHログインし直して先ほどのファイルを表示します。

以下のように順番が替わっていれば成功です。

$ sudo cat /proc/asound/modules 
 0 snd_usb_audio
 1 snd_bcm2835

録音のハードウェアを確認します。

$ arecord -l
**** List of CAPTURE Hardware Devices ****
card 0: Device [USB PnP Sound Device], device 0: USB Audio [USB Audio]
  Subdevices: 1/1
  Subdevice #0: subdevice #0

カード0、デバイス0でUSBマイクが認識されているので、環境変数ALSADEVに、”plughw:0,0″を設定します。

$ echo 'export ALSADEV="plughw:0,0"' >> ~/.bashrc
$ source ~/.bashrc

ホームディレクトリに.asoundrcファイルを作成します。

$ vi ~/.asoundrc
pcm.!default {
        type hw
        card 1
}

ctl.!default {
        type hw
        card 0
}

以下のコマンドで5秒間の録音を試します。USBマイクに向かって何か話してみてください。

$ arecord -d 5 -f cd -D plughw:0,0  test.wav

以下のコマンドで再生できたら成功です。

$ aplay  test.wav

これで、録音と再生のテスト完了です。

Juliusの動作確認

Juliusのインストール

Julius rev.4.5(2020/3/12現在)のソースコードをダウンロードし、コンパイルします。

$ git clone https://github.com/julius-speech/julius.git
$ cd julius
$ export CFLAGS=-O3
$ ./configure --with-mictype=alsa
$ make
$ sudo make install

これで/usr/local/binにjulius本体のほか、以降で使用する関連プログラムyomi2voca.plやmkdfa.plなども同じ場所にインストールされます。

Juliusのコンパイルができたら設定を確認します。-settingを付けて実行すると以下のように表示されると思います。

$ julius -setting
JuliusLib rev.4.5 (fast)

Engine specification:
 -  Base setup   : fast
 -  Supported LM : DFA, N-gram, Word
 -  Extension    : LibSndFile
 -  Compiled by  : gcc -O3 -fPIC

Library configuration: version 4.5
 - Audio input
    primary A/D-in driver   : alsa (Advanced Linux Sound Architecture)
    available drivers       : alsa
    wavefile formats        : various formats by libsndfile ver.1
    max. length of an input : 320000 samples, 150 words
 - Language Model
    class N-gram support    : yes
    MBR weight support      : yes
    word id unit            : short (2 bytes)
 - Acoustic Model
    multi-path treatment    : autodetect
 - External library
    file decompression by   : zlib library
 - Process hangling
    fork on adinnet input   : no
 - built-in SIMD instruction set for DNN
   
    NONE AVAILABLE, DNN computation may be too slow!

Try `-help' for more information.

日本語ディクテーションキットのダウンロード

日本語音声の学習済みモデルが準備されていて、自分で音声の学習をさせなくても日本語の音声をテキストに変換できるようになっていてとてもありがたいです。

GitHubからのダウンロードにはそれなりに時間がかかるので、コマンドプロンプトが返ってくるまで気長に待ちます。

$ cd ..
$ git clone https://github.com/julius-speech/dictation-kit
$ cd dictation-kit
$ julius -C main.jconf -C am-gmm.jconf -input mic
...
<<< please speak >>>

上記のように音声入力待ち状態になったら、何か日本語を話してみましょう。

ある程度大きな声でハッキリと発音した方が良いです。

「おはようございます」と話しかけたときに以下のように表示されれば成功です。

pass1_best:  おはよう ござい ます 。
pass1_best_wordseq: <s> おはよう+感動詞 ござい+動詞 ます+助動詞 </s>
pass1_best_phonemeseq: silB | o h a y o: | g o z a i | m a s u | silE
pass1_best_score: -2838.269287
### Recognition: 2nd pass (RL heuristic best-first)
STAT: 00 _default: 3983 generated, 1356 pushed, 185 nodes popped in 118
sentence1:  おはよう ござい ます 。
wseq1: <s> おはよう+感動詞 ござい+動詞 ます+助動詞 </s>
phseq1: silB | o h a y o: | g o z a i | m a s u | silE
cmscore1: 1.000 0.813 0.927 0.677 1.000
score1: -2867.838379

<<< please speak >>>

Ctrl+Cで終了します。

何も学習させずに(ユーザが、追加で)いきなりこれが動くってスゴイことだと思います。しかもこれが無料で使えます。音声認識のハードルはどんどん下がりますね。

Juliusを起動して最初の発話は音量の調整等に使われるため、正しく認識できない場合があるそうです。

また、あまりに短い単語だけの発話などは認識してくれません。

辞書の定義

声で何かを操作する場合、特定の単語を聞き分けてくれれば良いというケースはそれなりにあるのではないかと思います。

そして、何かに特化した作業をさせたい場合に聞き分けて欲しい単語が一般的な用語ではないことはよくあると思います。

Juliusは、特殊な単語であっても辞書を定義することで聞き取ってくれる機能があるので、それを試していきます。

ホームディレクトリに julius_work ディレクトリを作り、その下で作業することを想定した説明になります。

$ cd
$ mkdir julius_work
$ cd julius_work
$ vi member.yomi

member.yomi の中身は以下のようにして単語と読みを空白区切りで書きます。

アムロ     あむろ
フラウボウ ふらうぼー
マチルダ   まちるだ

これをJuliusが読める辞書形式に変換します。

$ yomi2voca.pl member.yomi > member.dict

member.dict の中身は以下のようになります。

$ cat member.dict 
アムロ a m u r o
フラウボウ f u r a u b o:
マチルダ m a ch i r u d a
ブライト b u r a i t o

上記のローマ字に似た発音表現の説明はこの資料にあります。

この表記を理解している人はyomi2voca.plで変換せずとも自力で.dictファイルを書けば良いようです。

専門用語を認識できるよう作成した member.dict と日本語の音響情報を入力してJuliusを立ち上げます。

$ julius -w member.dict -h ../dictation-kit/model/phone_m/jnas-tri-3k16-gid.binhmm -hlist ../dictation-kit/model/phone_m/logicalTri -input mic

これで定義した単語を認識するようになれば成功です。

この方法では、定義した単語は聞き取ってくれるものの、最初の実行時に様々な日本語を聞き取ってくれた機能は発揮されず、「こんにちは」などと話しかけても登録した3つの単語のどれか(「アムロ」など)として認識されます。

文法の定義

上記の方法で特定の単語を認識できるようになりました。単語のみ認識できれば事足りる用途もあるとは思いますが、やはり文章を認識してくれると、できることの幅が広がりますよね。

次に文法を定義する方法を紹介します。やり方は同じくこの資料を参考にしています。

まず、文法規則である.grammarファイルを作成します。

$ vi phrase.grammar

例えば以下のように定義します。

TOP    : NS_B NAME_S HA NAME_S GA VERB NS_E
NAME_S : NAME
NAME_S : NAME SAN
VERB   : LIKE
VERB   : DISLIKE

TOPが最終的な文章を表していて、「〜は〜が好き」「〜は〜が嫌い」という文を定義しています。〜には人名が入り、人名は呼び捨てとさん付けどちらでもOKにしてあります。

NAME、HA、GA、LIKE、DISLIKEが末端の構成要素であり、以下の語彙辞書.vocaファイルに定義していきます。

$ vi phrase.voca

こちらは先ほど見た.dictファイルに似ています。

% NAME
アムロ a m u r o
フラウボウ f u r a u b o:
マチルダ m a ch i r u d a
ブライト b u r a i t o
% SAN
さん s a N
% HA
は w a
% GA
が g a
% LIKE
好き s u k i
% DISLIKE
嫌い k i r a i
% NS_B
<s> silB
% NS_E
</s> silE

これをJuliusが読み込める形式に変換します。

$ mkdfa.pl phrase

3つのファイルが生成されます。それぞれ中身は以下のようになっていました。
①phrase.term

0 NAME
1 SAN
2 HA
3 GA
4 LIKE
5 DISLIKE
6 NS_B
7 NS_E

②phrase.dfa

0 7 1 0 0
1 4 2 0 0
1 5 2 0 0
2 3 3 0 0
3 0 4 0 0
3 1 5 0 0
4 2 6 0 0
5 0 4 0 0
6 0 7 0 0
6 1 8 0 0
7 6 9 0 0
8 0 7 0 0
9 -1 -1 1 0

③phrase.dict

0 [アムロ] a m u r o
0 [フラウボウ] f u r a u b o:
0 [マチルダ] m a ch i r u d a
0 [ブライト] b u r a i t o
1 [さん] s a N
2 [は] w a
3 [が] g a
4 [好き] s u k i
5 [嫌い] k i r a i
6 [<s>] silB
7 [</s>] silE

前節でyomi2voca.plで生成した.dictとは少し形式が違っていました。

文生成のテストをします。

$ generate phrase
Stat: init_voca: read 11 words
Reading in term file (optional)...done
8 categories, 11 words
DFA has 10 nodes and 12 arcs
----- 
<s> マチルダ は アムロ が 嫌い </s>
<s> マチルダ は ブライト が 嫌い </s>
<s> ブライト は フラウボウ さん が 嫌い </s>
<s> フラウボウ は マチルダ が 嫌い </s>
<s> フラウボウ は アムロ が 嫌い </s>
<s> マチルダ さん は マチルダ さん が 好き </s>
<s> アムロ さん は フラウボウ さん が 嫌い </s>
<s> マチルダ は フラウボウ が 嫌い </s>
<s> マチルダ は アムロ さん が 嫌い </s>
<s> フラウボウ は フラウボウ が 嫌い </s>

確かに想定した文法通りの文章例が表示されました。(意味合いとしてはほぼ全て間違っていますが・・・)

では、Juliusを動かしてみましょう。

$ julius -gram phrase -h ../dictation-kit/model/phone_m/jnas-tri-3k16-gid.binhmm -hlist ../dictation-kit/model/phone_m/logicalTri -input mic

こんな風に認識できれば成功です。

pass1_best: <s> アムロ は マチルダ さん
pass1_best_wordseq: 6 0 2 0 1
pass1_best_phonemeseq: silB | a m u r o | w a | m a ch i r u d a | s a N
pass1_best_score: -4113.165527
### Recognition: 2nd pass (RL heuristic best-first)
STAT: 00 _default: 27 generated, 27 pushed, 12 nodes popped in 169
sentence1: <s> アムロ は マチルダ さん が 好き </s>
wseq1: 6 0 2 0 1 3 4 7
phseq1: silB | a m u r o | w a | m a ch i r u d a | s a N | g a | s u k i | silE
cmscore1: 1.000 1.000 1.000 1.000 0.447 1.000 0.718 1.000
score1: -4241.623535

成功例を載せましたが、「好き」と言ったのに「嫌い」と認識されたりすることも多く、実際はなかなか難しかったです。

もう少し長めの単語だと認識を誤る割合が減るのではないかと思います。

しかし、これについても自分の発する音声を全く学習させずに認識できてしまうことに驚きました。

プログラムとの連携

これまでに紹介した方法で音声認識して結果をターミナルに表示することはできました。

しかし、実際にやりたいことは、音声認識した結果に応じて何かをさせるということだと思います。

Juliusには自作プログラムと連携するために、プラグインモードとサーバ・クライアントモードの2種類の方法が提供されています。

プラグインモードによる連携

まず1つ目の方法としてプラグインモードを試していきます。

Juliusからプラグインプログラムを呼び出す方法です。

まず、test.cなどとしてプラグインのプログラムを作成します。

$ mkdir ~/julius_work
$ cd ~/julius_work
$ vi test.c

以下は、公式ドキュメントで紹介されているプラグインの例(test.c)です。

#include <stdio.h>
#include <string.h>
int get_plugin_info(int opcode, char *buf, int buflen)
{
    switch(opcode) {
    case 0:
        strncpy(buf, "simple output plugin", buflen);
        break;
    }
    return 0;
}
void result_best_str(char *result_str)
{
    if (result_str == NULL) {
        printf("\t[failed]\n");
    } else {
        printf("\t[%s]\n", result_str);
    }
}

get_plugin_info関数は必須とのこと。常に上記のような定義で良いと思います。

肝心なのはresult_best_strで、ここに音声認識した結果を使った処理を書くことで、独自の処理を実行できるようになります。

上記例では、result_strが空(NULL)だったら[failed]と表示、result_strとして何らかの認識結果文字列が来たらそれを[認識結果文字列]として表示するプログラムになっています。

これを-sharedオプションを付けてコンパイルします。〜.jpiというファイル名にしておく必要があります。

$ gcc -shared -o test.jpi test.c

以下のように-plugindirオプションで.jpiファイルがあるディレクトリ(ここではカレントディレクトリ)を指定してJuliusを実行します。

$ julius -plugindir . -C ../dictation-kit/main.jconf -C ../dictation-kit/am-gmm.jconf -input mic

「こんにちは」と話しかけて以下のように表示されれば成功です。

pass1_best:  こんにちは 。
pass1_best_wordseq: $lt;s> こんにちは+感動詞 </s>
pass1_best_phonemeseq: silB | k o N n i ch i w a | silE
pass1_best_score: -2689.343506
### Recognition: 2nd pass (RL heuristic best-first)
STAT: 00 _default: 9143 generated, 1150 pushed, 221 nodes popped in 108
sentence1:  こんにちは 。
wseq1: <s> こんにちは+感動詞 </s>
phseq1: silB | k o N n i ch i w a | silE
cmscore1: 0.606 0.649 1.000
score1: -2701.078613
 [こんにちは 。]

最後に[こんにちは 。]と表示されていることがこれまでと異なるところで、Juliusによる音声認識結果文字列がプラグインプログラムであるtest.c内result_best_strに渡され、表示されたということになります。

printf部分を自作プログラムで置き換えたり、systemコールで何らかの外部プログラムを実行したりすれば、様々なことができると思います。

サーバ・クライアントモードによる連携

次にサーバ・クライアントモードによる自作プログラムとの連携について説明します。

$ cd ~/dictation-kit
$ julius -C main.jconf -C am-gmm.jconf -input mic -module

クライアントのサンプルとしてjclient.plが提供されているので、もう1つターミナルを立ち上げて以下を実行します。

$ jclient.pl

「こんにちは」と話しかけると以下のように表示されます。

<RECOGOUT>
  <SHYPO RANK="1" SCORE="-2333.823486">
    <WHYPO WORD="" CLASSID="<s>" PHONE="silB" CM="0.746"/>
    <WHYPO WORD="こんにちは" CLASSID="こんにちは+感動詞" PHONE="k o N n i ch i w a" CM="0.648"/>
    <WHYPO WORD="。" CLASSID="</s>" PHONE="silE" CM="1.000"/>
  </SHYPO>
</RECOGOUT>

ただ、Raspberry Pi上で立ち上げたサーバにRaspberry Piのクライアントプログラムからつないでいる状態であり、サーバ・クライアント方式にしている意味がほぼ無いため、今度はパソコンからLANにつながっているRaspberry Piのサーバに接続してみます。

jclient.plをパソコンにダウンロードやファイル転送するなどして以下のIPアドレスをRaspberry Piのものに書き換えます。

#my $host = "localhost";
my $host = "192.168.179.21";

パソコンからjclient.plを実行します。

PC$ ./jclient.pl

Raspberry Piのマイクに話しかけて上記と同様の結果が返ってくれば成功です。

以下はPythonでサーバから受け取った音声認識結果(XML)の内容に応じて何かするプログラムの例です。

# coding: UTF-8
import socket
import xml.etree.ElementTree as ET
import re

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(('192.168.179.51', 10500))
buf = ''
while True:
    data = sock.recv(4096)
    #print('data:' + data)
    if re.search(r'\.', data):
        buf += data
        buf = buf.replace('\n', '')
        buf = buf.replace('\r', '')
        buf = re.sub(r'</RECOGOUT>.*$', '</RECOGOUT>', buf)
        #print('buf:' + buf)
        if re.search(r'', buf):
            try:
                with open('INPUT.xml', mode='w') as f:
                    f.write("\n")
                    f.write(buf)

                root = ET.parse('INPUT.xml')
                top = root.getroot()
                #print(top[0][1].tag)
                #print(top[0][1].attrib)
                if re.match('k o N n i ch i w a', top[0][1].attrib['PHONE']):
                    print('Hello Julius!')
                else:
                    print('...')
            except:
                print('XML Parser Error.')
        buf = ''
    else:
        buf += data

ドットがデータの区切りとして入ったり、<RECOGOUT>以外の余計なタグを消すなどの処理をした後にXMLをパースし、「こんにちは」だったら’Hello Julius!’、それ以外の言葉なら’…’を表示するというプログラムにしてみました。

Hello Julius!
...
Hello Julius!

このサーバ・クライアントモードは、通常のスマートスピーカっぽい機能(音声データのやり取りをしない点で異なる)ではあるのですが、Raspberry Piで音声認識した結果としてのXMLをネットワークを通して受け取りたいことがはたしてあるだろうかという疑問が生じました。

実際には音声認識した結果に応じて次に行う処理をRaspberry Pi上で決めてしまい、ネットワークにつながった機器に短いコマンドを送るという方が普通ではないかと思いました。

このことから、先に説明したプラグインモードの方が使う場面が多いように思います。

トラブルシューティング

おかしな認識になる

git-lfsをインストールせずにディクテーションキットをgit clone(ダウンロード)するとバイナリファイルが不足して正常に動作しません。apt-get install git-lfsでインストールしてから再度取得してみてください。

スピーカから音が出ない

音声関連の準備を再度確認してみてください。私の場合、ホームディレクトリに.asoundrcを作り忘れて音が出ないことがありました。

音量が小さい

alsamixerを起動して音量を最大にするなどしてみると良いでしょう。音量最大で音が割れるようなら少し小さくします。

まとめ

無料の音声認識ツールJuliusRaspberry Pi上で動かし、音声認識させることができました。

無料、かつユーザによる追加学習無しで日本語を聞き取ってくれるのはとてもありがたいです。

しかしながら、現時点のデフォルトの音声認識能力としては、市販のスマートスピーカと比べるとかなり劣ると言わざるを得ません。かなりはっきりとキビキビ話しても誤認識は多いです。

単語を辞書登録し、入力を限定的にして区別させる使い方であれば十分実用に耐えうると感じました。
Juliusのさらなる進化に期待しています!

書籍紹介

新しいLinuxの教科書

Linuxをコマンドラインで操作するための初歩から、スクリプト作成などの応用、Gitによるバージョン管理など、Linux上で開発を行うために必要なスキルが一通り身につきます。

参考文献

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