Juliusを使った音声認識で遊ぼうとしたら大変だった話
先日、卒業研究の中間発表が終わりました。
少し時間もできたので、そろそろ高専祭に向けてEject工作の準備を進めなければなりません。
ネタは既に決まっていてTwitter等を見ている方はわかると思いますが、「Ejectカー」です。(詳しくはまた別途記事を書きます。)
機構自体は既に出来上がっていて、もう後は組み立てるだけにところまでは来ています。
で、問題は「何をトリガーにして動かすか」です。
候補としては「Twitterのリプライorハッシュタグ」を考えています。
まぁ他にも色々使えそうなものがあれば使いたいなぁと色々妄想していたのですが、そこで思いついたのが「音声認識」でした。
で、とりあえず音声認識だけやってみようということで、軽い気持ちで始めたのが今回の苦労の始まりでした。
Julius
Juliusは大語彙音声認識エンジンです。
大量の単語を辞書として用意し音声を認識するようです。
(詳しいことは分かりません)
なお、プラットフォームはWindowsでもLinuxでもMacでも使えるようです。
他の音声認識エンジンも探したのですが、フリーで利用できて日本語の情報が集めやすいという理由からJuliusを採用しました。
まずは動かす
何も考えずにJulius最新版をダウンロードしたのが最初の過ち(闇)でした。
どうやら(現段階での)最新版であるv4.3.1は色々仕様も変わっているらしく、Webに存在する情報とは結構な差異があり起動すらできませんでした。
2時間ほど進捗がないまま、過去のバージョンをダウンロードしてみることに。
Webでの情報はほとんどがv4.2.3のようだったので、おとなしくv4.2.3を使いました。
アーカイブを展開して、./configureしてmakeしてmake installしました。
次にJuliusの文法認識キットをダウンロードします。こちらはv4.1を利用しました。
展開するとサンプルの設定ファイルと文法ファイルが入っているのでこれを使います。
設定ファイルであるhmm_mono.jconfを編集します。
「-input mic」の行のコメントアウトをはずして、150行目付近にある音声の入力の設定をマイクにしましょう。
どのマイクを利用するかの設定は、私はよく分からなかったので、他のサイトをご覧頂ければと思います。
以下のコマンドを実行。
1 |
$ julius -C ./hmm_mono.jconf -gram SampleGrammars/fruit/fruit
|
-gramオプションで指定しているのが文法ファイルになります。今回は「SampleGrammars/fruit/fruit」ですね。
起動したらマイク入力からの入力が求められるので、何か話してみましょう。
「りんご3個です」と喋って、正しく認識できているか確認しましょう。
今回指定したサンプルの文法ファイルは「[フルーツ]が[n個」です」のような文法を認識するように記述されています。
文法ファイルに認識させたい文法、辞書ファイルに認識させたい言葉を記述していくことで、オリジナルの音声認識パターンを作ることができます。
また、マイクではなくファイルからの入力を行いたい場合は、設定ファイルの入力設定から「-input rawfile」とすればwavからの入力ができます。
Juliusに入力できるファイルはffmpegを利用することで簡単に作成できます。
1 |
ffmpeg -i input.wav -ar 16000 -ac 1 output.wav
|
自分で文法を定義する
ようこそ第二の闇へ。(何)
いや別に「文法を定義する」ことが闇ではないのです。
文法ファイル自体はとてもシンプルに書けますし、辞書ファイルだって怖くないです。
(そりゃ大規模なものを作ろうとするとさらなる苦悩が待っているだろうけど)
最初から複雑なものを作っても仕方ないのでまずは単語だけにしましょう。
まずは適当なディレクトリを作ります。
1 |
mkdir mydic
|
2つのファイルを作ります。
mydic.grammar(文法ファイル)
1 |
S : NS_B WORD NS_E
|
mydic.voca(辞書ファイル)
1
2
3
4
5
6
7
|
% WORD
みかん m i k a N
かまぼこ k a m a b o k o
% NS_B
<s> silB
% NS_E
<s> silE
|
BNFっぽく書けて簡単ですね。詳しいことは公式ドキュメントを見たほうがいいです。
silBとsilEは無音部分を意味します。
.vocaファイルの区切りはスペースではなくタブ文字のようです。(ここにハマったりもした)
なお、Linuxの場合、文字コードはEUC-JPがいいようです。(ここにもハマった)
文字コードは設定ファイルで変えられますがいまいち仕様が分かってないので、今後調査してみます。
文法ファイルと辞書ファイルができたら次はコンパイルしましょう。
文法認識キットの中に同梱されているコンパイラを使います。
1 |
./bin/mkdfa.pl ./mydic/mydic
|
さて、mydicディレクトリの中に「mydic.dfa」「mydic.dict」「mydic.term」が生成されたでしょうか?
生成された方は、おめでとうございます。
.termのみが生成され、「no .dfa or .dict file generated」と表示された方は、ようこそ深淵へ(ニッコリ)。
このコンパイラ、とても悪名高くネットを見ていてもあちこちで色々と言及されていました。
で、このメッセージを調べてみたのですが、有力な情報は何もヒットせず。。
サンプルとして用意されている文法ファイルも同じくコンパイルが通らず、さすがに諦めかけたのですが、もう少し頑張ってみることに。
このコンパイラ、拡張子からも分かるようにPerlで書かれていますね。。
ウェヒヒ…
printデバッグの始まりじゃ!!!!(☝ ՞ਊ ՞)☝
デバッグ過程は、ただただ怪しい場所にprint突っ込んでくだけでつまらないと思うので、割愛します。
調べてみた結果、「mkdfa.pl」が内部で呼んでいる「mkfa」が怪しい感じ。。
mkfaをそのまま実行してみるとエラーが発生します。
「/lib/ld-linux.so.2: bad ELF interpreter」
調べてみると、普通に必要なライブラリが無いってことっぽいらしいです。。
このエラー、64bit版のLinuxディストリから32bitのライブラリを使おうとして32bitのライブラリが入ってない場合に発生するらしいです。
確かに使っている環境はCentOS6.2の64bit版でした。
「共有ライブラリとかどうやって解決すんだよ・・・」とか思っていたのですが、調べてみればどうやらyumで簡単に入るらしい。。
1 |
yum install ld-linux.so.2
|
再度、mkfaを実行。
libasound.so.2: cannot open shared object file: No such file or directory
エラー、同じようにyumで入れる。
1 |
yum install libasound.so.2
|
もっかいmkfa実行。
libz.so.1: cannot open shared object file: No such file or directory
エラー・・・。これも入れる。
1 |
yum install libz.so.1
|
mkfa実行、これでmkfaのエラーが消えました!!
(そもそも最初からエラー出してくれればもう少し楽なのに)
気を取り直して、文法ファイルのコンパイルを!!
1 |
./bin/mkdfa.pl ./mydic/mydic
|
やっと動きましたね。。疲れた。
ここで生成されたファイルが実際にJuliusに読み込まれる文法ファイルになります。
Juliusの引数で指定して起動しましょう。
1 |
$ julius -C ./hmm_mono.jconf -gram ./mydic/mydic
|
マイクに向かって話してみて、正常に出力されれば成功です!!
(今回はみかんとかまぼこしか認識しないけど)
これをうまく使っていけば自分で音声コマンドを作成できますね。
Juliusをモジュールモードで利用する
Juliusを他のプログラムで扱う場合には、JuliusLib等がありますが、モジュールモードであればTCP通信だけで利用できるため、簡単にJuliusを扱えます。
Juliusを起動する際に-moduleを引数に指定することでモジュールモードとして利用できます。
モジュールモードではJuliusがサーバーとなり、ポート番号10500で接続を待ちます。
10500番ポートにTCP通信を行うことでデータのやり取りを行えます。
TCP通信が始まると、サーバーはマイクやネットワークからの音声入力を待ち、音声が入力されると結果がXMLで返却されます。
一回の送信毎に、送信されたデータの末端に「.」が付与されますので、それを利用してパースしたりします。
今回はクライアントをJavaで実装しました。
Socketを使うことで通信は簡単に出来ました。(通信は)
以下、ソースコードです。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
|
import java.net.*;
import java.io.*;
import javax.xml.parsers.*;
import org.w3c.dom.*;
class SocketTest{
public static void main(String args[]){
try{
Socket client = new Socket("localhost", 10500);
InputStream input = new DataInputStream(client.getInputStream());
BufferedReader reader = new BufferedReader(new InputStreamReader(input));
PrintStream output = new PrintStream(client.getOutputStream());
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
DocumentBuilder builder = factory.newDocumentBuilder();
String s;
StringBuilder sb;
int i = 0;
while(i < 10){
sb = new StringBuilder();
sb.append("<?xml version=\"1.0\" encoding=\"Shift-JIS\"?>");
while(true){
if((s = reader.readLine()) != null){
if(s.toString().equals(".")){
String data = sb.toString().replaceAll("<s>", "[s]").replaceAll("</s>", "[/s]");
//System.out.println(data);
InputStream stream = new ByteArrayInputStream(data.getBytes());
Node root = builder.parse(stream);
Node first = root.getFirstChild();
if(first.getNodeName().equals("RECOGOUT")){
NodeList nL = first.getChildNodes();
for(int j = 0; j < nL.getLength(); j++){
Node n = nL.item(j);
if(n.getNodeName().equals("SHYPO")){
StringBuilder sbWord = new StringBuilder();
float cm = 0;
int num = 0;
NodeList wL = n.getChildNodes();
for(int k = 0; k < wL.getLength(); k++){
Node w = wL.item(k);
if(w.getNodeName().equals("WHYPO")){
Element el = (Element)w;
if(!el.getAttribute("WORD").equals("。") && !el.getAttribute("WORD").equals("")){
System.out.println(el.getAttribute("WORD")+":"+el.getAttribute("CM"));
sbWord.append(el.getAttribute("WORD"));
cm += Float.parseFloat(el.getAttribute("CM"));
num++;
}
//System.out.println(el.getAttribute("WORD"));
}
}
System.out.println("WORDS:"+num);
System.out.println(sbWord+":"+cm/num);
}
}
}
System.out.println(first.getNodeName());
break;
}
sb.append(s);
}
}
//System.out.println(i+":------------------------------\n");
System.out.println("\n");
//i++;
}
output.print("DIE");
client.close();
}
catch(Exception e){
e.printStackTrace();
}
}
}
|
(今回に限ってはサーバーもWindows上で立てたため、文字コードはShif-JISにしています)
Juliusをモジュールモードで起動し、クライアントも起動しましょう。
正常に接続ができたら、マイクから入力してみてください。
サーバー起動コマンド例
1 |
$ julius -C ./hmm_mono.jconf -gram ./mydic/mydic -module
|
上記の起動コマンドでは、先ほど作った辞書を利用しているため、「みかん」と「かまぼこ」しか認識しません。
一応、返ってきたXMLに単語ごとの信頼度も含まれているため、合わせて表示しています。
Juliusのディクテーションキットに入っている文法ファイル・辞書ファイルを使うことで、デフォルトのままでは精度は低いですが文章の認識もできます。
文章認識の場合、複数の単語が含まれているため、今回のコードでは各単語の信頼度の平均を取るようにしています。
また、サイレント部分と句点は含めないようにしました。
要するに、文章になった場合は全体としての信頼度の平均値を取得します。
上記のコードでの幾つかポイントを説明しておきます。
まずは、XMLに対してプログラム側でXMLにプロローグを与えています。
返ってくるデータはプロローグを含まないXMLのため、XMLパーサにかけるとエラーを吐いて死にます。
なのでプロローグをJava側で与えてあげることで今回は暫定的に解決しました。
次に、受け取ったデータに対して、「<s>」「</s>」をそれぞれ「[s]」「[/s]」に置き換える処理を行っています。
文法認識キットやディクテーションキットのデフォルトの文法では、サイレント部分が「<s>」「</s>」で出力されます。
通常は問題ないのですがモジュールモードになった場合に問題となってきます。
XML中に山括弧がエスケープも何もされずに現れるため、JavaのXMLパーサがエラー吐いて死にます。(2回目)
正直、普通に考えてわかると思うのですが、そもそもなぜエスケープも行わずに出力するんでしょうか。。
なので、今回はひとまずパーサにかけるまえに文字列の置き換えを行うことで対応しました。
JavaのSocketで普通に通信できたので、あとはパースするだけだと簡単に考えていましたが、ここにも問題が隠れてましたね。。
まとめ
音声認識でEjectしよう!とか簡単に考えてごめんなさい。。
最初はGoogleかどっかでAPIとか公開されてると思ってました。
でも蓋を開けて見るとJuliusとかいうソフトウェアを扱うことになり、ここまでの過程でいくつも問題が浮上していました。
ここには書いてないですが、文字コードとの戦いももちろんありました。。
Linux版のJuliusはEUC-JPを推奨?のようでUTF-8で利用しようと思うとオプションで文字コード指定したりなど結構面倒だったりしました。
さらにLinuxだとmkdfa.plがうまく動かなかったので、一度Windows版でCygwin等を使いつつ文法ファイル・辞書ファイルをコンパイルしてLinuxに持ち込んでみたり。。
まぁとにかく今回は「音声認識を軽い気持ちで始めたら想像以上に大変だったし闇は深かったよ」ってまとめておきます。
ディクテーションキットとか使うともっと自然な文章の認識もできそうですが、デフォルトだと認識精度があまり高くなく語彙も少ないため使いにくいです。
語彙に関しては自分で増やしていくこともできそうですが、認識精度はちょっとどうするか。。
SVMを使った機械学習で認識精度を向上させる方法などを考えている人もいましたが、それってもはや研究の領域ですよね。
しかもSVMってことは学習用のデータを大量に用意しないといけないし、「ちょっと遊ぶ」という場合には現実的ではないですよね。
まぁ、まだまだ個人で音声認識するには少し時代が早過ぎるのかもしれませんね。
もう数年でAPIも徐々に公開されてきたらもっと手軽に遊べるようになるんでしょうけど。
Juliusに関してはこのあとディクテーションキットを含め色々検討を重ねて、最終的には音声認識をEjectに応用して遊んでみたいと思います。。
では。
参考にしたサイト
The Julius Book
telnetでJuliusのモジュールモードを試してみる
Julius の記述文法を用いて音声認識精度をあげてみた
cygwin 環境で mkdfa.plが動作しない
Juliusの辞書が作れない