産業現場で使える!Raspberry Pi +USBマイクで音声認識入門 、Julius、VOSK

Raspberry Piにマイクを繋いで実現する音声認識は、用途によって有効的で敷居もそれ程高くありません。音声対話とは違う音声認識だけであれば、それほどマシンスペックも要求されません。
音声認識ソフトやサービス、ライブラリは有料無料を問わなければたくさん存在します。特にGoogleやAmazon、IBMをはじめとしたAPIサービスをよく目にします。

今回はローカル環境(オフライン)で実施したいため、高効率で汎用性が高い「Julius」とPythonプログラムで容易に扱える「VOSK」を検討しました。どちらも無料で手軽に始められます。ローカル環境なのでセキュリティもプライバシーも心配が要りません。

Raspberry Pi のスペックでは、音声認識ソフトがなんでも良いわけではありません。日本語の音声認識モデルがあまりにも大きいと流石に処理が追いつきません。
Juliusは基本のディクテーションキットで試し、VOSKは小さい認識モデル(small)で試してみました。

※記事内にあるPythonのサンプルプログラムはChatGPTで作成しました。動作テストの表現方法として載せています。エラー処理・終了処理は不完全ですのでご理解ください。

今回の環境

今回も実行したテスト機は「PL-R5M」です。Raspberry Pi Compute Module 5を使用したモデルです。

使用した機材

  • PL-R5M(Raspberry Pi CM5)
  • Jabra SPEAK 510(USBマイク)

使用した環境:

  • Raspberry Pi OS (bookworm) 64bit
  • Python 3.11.2
  • pip 23.0.1

USBマイクの確認

今回使用したUSB接続のマイクは、「Jabra SPEAK 510」で、会議など複数人で使用するのが目的のマイク&スピーカーです。
単純にマイクだけのUSB機器もあり、USBを繋げば使用できるものがほとんどです。

Raspberry Pi OSのデスクトップでも、繋げただけで選択できるのが分かります。

しかし、Pythonのプログラムで使う際、マイクのデバイスを指定した方が確実なので、指定するためのインデックス番号を先に調べておきましょう。
インデックス番号を指定しなくても、デフォルトであれば問題がないこともあります。
今回はマイクとスピーカーを兼ねている機器ということもあり調べて設定しました。

USBマイクのカード番号:
Raspberry Pi OSだと、arecordコマンドで調べられます。
今回接続したJabra SPEAK 510はカード番号2、デバイス番号0でした。 ALSA デバイス名でいうところのhw:2,0または plughw:2,0です。

arecord -l

**** List of CAPTURE Hardware Devices ****
card 2: USB [Jabra SPEAK 510 USB], device 0: USB Audio [USB Audio]
  Subdevices: 1/1
  Subdevice #0: subdevice #0

このカード番号を指定しないとマイクのキャプチャができませんので、接続したら一度調べておきます。

Juliusのインストール

最初はJuliusから試します。
Juliusは有名ですが、どうも情報が古く公式サイトもリンク切れがあったりして、特にRaspberry Pi で導入するのは難儀しました。Julius本体も2020年が最後のv4.6
です。ドキュメントはありますが、インストール段階から躓くことがあると思います。

公式サイトのインストール方法を読むと、ビルドする際にRaspberry Pi OSではALSAを明示的に指定しないとダメらしいことが分かりました。
また、ビルドするConfigureは 2008年版と古く、Raspberry Pi 5系 (aarch64, ARM64) を認識できず、新しいファイルをダウンロードしないとなりませんでした。

make uninstallがないため、後で削除しやすいように、インストール先をsudo権限の要らないローカル環境($HOME/.local)に変更してあります。

次の流れで順番に作業します。
git cloneで取得していますが、ソースからでも解凍すれば同じです。ディレクトリ名が異なるので注意してださい。(wget https://github.com/julius-speech/julius/archive/v4.6.tar.gz

sudo apt update
sudo apt install build-essential zlib1g-dev libsdl2-dev libasound2-dev

git clone https://github.com/julius-speech/julius.git

cd julius
wget -O config.guess 'https://git.savannah.gnu.org/cgit/config.git/plain/config.guess'
wget -O config.sub   'https://git.savannah.gnu.org/cgit/config.git/plain/config.sub'
chmod +x config.guess config.sub

cp config.guess support/config.guess
cp config.sub   support/config.sub

./configure --build=aarch64-unknown-linux-gnu --with-mictype=alsa --prefix=$HOME/.local
make
make install

export LD_LIBRARY_PATH=$HOME/.local/lib:$LD_LIBRARY_PATH
export PATH=$HOME/.local/bin:$PATH

config.guessとconfig.subは実行権を与えた後、supportディレクトリコピーしました。

configureで指定したオプションの意味は次の通りです。

# Raspberry Piのaarch64を指定)
--build=aarch64-unknown-linux-gnu

# ALSAを指定
--with-mictype=alsa

# ローカルディレクトリに指定することでsudoが必要無い
--prefix=$HOME/.local

最後に2行あるexportコマンドは、ロカールディレクトリに指定したJuliusのパスを通すために必要です。

上記のコマンドで無事に通ったConfigureの結果はこうなりました。

最後に、Juliusのバージョンをチェックしてインストールとパスが通ったのか確認します。

julius -version

これでOKです。

Julius日本語ディクテーションキット

ディクテーションキットv4.5を入手します。他にも話し言葉モデルキット、講演音声モデルキットがありますが、ここでは基本のキットだけで試しました。

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

しかし、今回は公式のリンクだとv4.5もv4.4もwgetでダウンロードできませんでした。(OSDNからのダウンロード)
仕方ないので、ミラーサイトからダウンロードしています。ダウンロードができない人はミラーも試してみてください。

cd julius
wget https://ftp.iij.ad.jp/pub/osdn.jp/julius/71011/dictation-kit-4.5.zip
unzip dictation-kit-4.5.zip

USBマイクのデバイス番号を調べる

最初に接続したUSBマイクのカード番号を調べます。この指定が合っていないとできません。

arecord -l
**** List of CAPTURE Hardware Devices ****
card 2: USB [Jabra SPEAK 510 USB], device 0: USB Audio [USB Audio]
  Subdevices: 1/1
  Subdevice #0: subdevice #0

接続したJabra SPEAK 510はカード番号が2でした。今回は一時的に認識させるコマンドを実行します。

export ALSADEV=hw:2

上記は再起動すると元に戻ってしまうため、常時使いたい場合は~/.profileに追記してください。

Juliusを実行

準備が整いましたので、コマンドで実行してみます。明示的なオプションとして-input micを付けておきました。-nostripは無くても動作しますが、発言がないゼロの認識をしてしまいターミナル画面が埋まってしまいます。これも付けておくと良いでしょう。

julius -C main.jconf -C am-gmm.jconf -nostrip -input mic

実行後に長いメッセージが表示された後、<<< please speak >>>でターミナル表示が止まったら話しかけます。

いくつか話しかけた実行結果は次の通りです。

最初に「テスト」と発言しました。
1回目は「ベスト」になり、2回目は少し余分に拾ってしまいましたが、「テスト」は認識できています。

次は「停止」と発言してみます。

天使や変身と間違えられました。しかし3回目で認識しています。

最後に、ついつぶやいてしまった「うまく認識しない」を拾われたところ、1回で正しく認識されているという皮肉な結果になりました。

精度はあまり的確には認識しませんでした。恐らく、マイクが高性能な指向性があるからと思われます。
音声認識が細かいように感じます。
小さな物音も拾って言葉として認識している印象がします。

ある意味で、もう少しチープなマイクか、口元にマイクがあれば確実に認識してくれそうな印象でした。
物音が大きい広い場所で使う想定なら、ノイズを除去する以外に間違わない言葉選びは重要かもしれません。

気が付いたのは、文章としてちゃんと句点が付くことですね。
時折見かけた以下の表示は、発言が短い場合には以下のように表示されて認識されません。

<input rejected by short input>
STAT: skip CMN parameter update since last input was invalid

単語として「テスト」や「停止」のような3文字だと短いので何度か出てきてしまいます。むしろ少し長い10文字くらいが認識率は良い気がしました。

JuliusのサーバーモードとPythonプログラム

産業用途で想定される使い方の1つとして、両手が塞がった状態や手が汚れている状態で、システムを一時的に止めたいことはありませんか。
足で操作するスイッチなどもあるかもしれませんが、「停止」とマイクに話すだけで一時停止できたら便利で時間的ロスも少なそうです。

サンプルプログラムは、「停止」と「開始」の発言をマイクから受けた処理をするPythonプログラムです。
「停止」は「→停止認識」として表示し、「開始」は「→開始認識」としました。それ以外の言葉はそのまま認識できた言葉で表示するだけです。
特定の言葉で処理を分岐させる状況を想定しました。

今回のように、Juliusで認識された言葉を発端に、Pythonプログラムで処理するため、Juliusを-moduleオプションを付けてサーバーモードとして実行します。
サーバーモードだと、Socket通信としてTCPポート(10500)でXML形式の音声データを受け取れます。

実機のターミナルで、先にJuliusをサーバーモードで起動しておきます。
その後、サンプルのjulius_client.pyを作成し、SSH接続環境か実機の別ターミナル画面でPythonを実行すると発言を認識して表示します。

Juliusをサーバーモードで起動:

julius -C main.jconf -C am-gmm.jconf -module -input mic -nostrip

このjulius_client.pyを実機ならば別のターミナルを開いて実行するか、SSH接続経由で実行します。

python3 julius_client.py

※Crtl + Cで強制終了できます。

julius_client.py:

import socket
import xml.etree.ElementTree as ET

HOST = "127.0.0.1"
PORT = 10500

client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect((HOST, PORT))
print("Connected to Julius module server")

recv_buf = ""

def parse_words(xml_text):
    try:
        root = ET.fromstring(xml_text)
        return [whypo.attrib["WORD"] for whypo in root.findall(".//SHYPO/WHYPO")
                if "WORD" in whypo.attrib and whypo.attrib["WORD"] != "[s]"]
    except Exception:
        return []

print("Julius module client 起動中... '停止'/'再開' 判定サンプル")

try:
    while True:
        data = client.recv(4096).decode("utf-8", errors="ignore")
        if not data:
            continue
        recv_buf += data
        while "<RECOGOUT>" in recv_buf and "</RECOGOUT>" in recv_buf:
            start = recv_buf.index("<RECOGOUT>")
            end = recv_buf.index("</RECOGOUT>") + len("</RECOGOUT>")
            xml = recv_buf[start:end]
            recv_buf = recv_buf[end:]
            words = parse_words(xml)
            if not words:
                continue

            sentence = " ".join(words)
            print(f"認識結果: {sentence}")

            if "停止" in words:
                print("→ 停止認識")
            elif "再開" in words:
                print("→ 再開認識")

except KeyboardInterrupt:
    print("終了します")
finally:
    client.close()

実行した結果:

Connected to Julius module server
Julius module client 起動中... '停止'/'再開' 判定サンプル
認識結果:  停止 。
→ 停止認識
認識結果:  再会 。
認識結果:  停止 。
→ 停止認識
認識結果:  作業 を 再開 。
→ 再開認識
認識結果:  さようなら 。
認識結果:  こんにちは 。

「再開」が同音異義語の「再会」になってしまい、正しく認識できていませんので、「作業を停止」や「作業を再開」の方が良さそうです。

Juliusは、基本のディクテーションキットしか使っていません。それもあって認識精度に多くは望めませんでした。ただ、認識速度は速くて、単語も一文もそれなりに認識してくれます。

VOSKのインストール

次はVOSKを試してみます。
こちらはPythonで扱うのに適していて、sounddeviceと共にインストールすればすぐに使えます。

はじめに日本語音声モデルをダウンロードし、適切な場所に保存します。
VOSKのバージョンは0.22です。なるべく軽量なsmallモデルを選びました。bigモデルでも動作はしますが、メモリー展開するため8GBモデルが良いでしょう。

cd
wget https://alphacephei.com/vosk/models/vosk-model-small-ja-0.22.zip
unzip vosk-model-small-ja-0.22.zip
mv vosk-model-small-ja-0.22 model

4行目のmvコマンドで、modelという名前に変更しています。

VOSK本体のインストールはpipで行います。
pipは仮想環境でイントールすることが前提になっていますので、vosk_envとしました。(PEP 668問題)
sounddeviceも合わせてインストールします。PortAudioと連携するモジュールです。

python3 -m venv vosk_env
source vosk_env/bin/activate
pip3 install vosk sounddevice

最終的に仮想環境から抜けるにはターミナルでdeactiveで元に戻れます。

PortAudioで使うマイクのインデックス番号

Jabra SPEAK 510 は、card 2でdevice 0として認識されていました。(hw:2,0または plughw:2,0
この情報からデバイス番号を指定したのですが上手く行かないことがあります。

どうやらPythonで扱うPortAudioとはインデックス番号が異なりました。
プログラム内でインデックス番号を取得して指定するやり方もありますが、先にサンプルプログラムに追記して出力させてみました。(エラーは変わらず出ます)

コード内にprint(sd.query_devices())を追記して調べてみた結果:

  0 vc4-hdmi-0: MAI PCM i2s-hifi-0 (hw:0,0), ALSA (0 in, 2 out)
  1 Jabra SPEAK 510 USB: Audio (hw:2,0), ALSA (1 in, 2 out)
  2 sysdefault, ALSA (0 in, 128 out)
  3 hdmi, ALSA (0 in, 2 out)
  4 pulse, ALSA (32 in, 32 out)
* 5 default, ALSA (32 in, 32 out)

インデックス番号は1でした。ちょっとややこしいですね。
VOSKをsounddevice(PortAudio)経由で使う ので同じ番号にならない事例でした。

USBマイクを使うなら、先にマイクのインデックス番号を調べてからの方が良さそうです。
エラーが表示されなくても、マイクに話しかけても何も起きない場合などに陥ったら、次のコードを実行してPortAudioで使うインデックス番号を調べてみてください。

音声入力デバイスのインデックス番号を調べるためのコード

次を実行すると、ALSAとPythonで扱うインデックス番号の2つが表示されます。
使用するUSBマイクで音声入力できない時、インデックス番号を知るための参考にしてください。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import subprocess
import sounddevice as sd
import re

# 1. arecord -l から ALSA デバイスを取得
def get_alsa_devices():
    result = subprocess.run(['arecord', '-l'], stdout=subprocess.PIPE, text=True)
    devices = []
    lines = result.stdout.splitlines()
    card = None
    for line in lines:
        # card行を検出
        m_card = re.match(r'^card (\d+): (.+?)\s+\[.*\], device (\d+): (.+?)\s+\[.*\]', line)
        if m_card:
            card_num = int(m_card.group(1))
            card_name = m_card.group(2).strip()
            device_num = int(m_card.group(3))
            device_name = m_card.group(4).strip()
            devices.append({
                'card': card_num,
                'card_name': card_name,
                'device': device_num,
                'device_name': device_name
            })
    return devices

# 2. Python / PortAudio デバイスを取得
def get_sounddevice_devices():
    devices = []
    for i, dev in enumerate(sd.query_devices()):
        if dev['max_input_channels'] > 0:
            devices.append({
                'index': i,
                'name': dev['name'],
                'max_in': dev['max_input_channels'],
                'max_out': dev['max_output_channels']
            })
    return devices

# 3. 両方を表示
def main():
    print(" ALSA (arecord -l)")
    alsa_devices = get_alsa_devices()
    for d in alsa_devices:
        print(f"card {d['card']}, device {d['device']}: {d['card_name']} - {d['device_name']}")

    print("\n Python / sounddevice")
    sd_devices = get_sounddevice_devices()
    for d in sd_devices:
        print(f"index {d['index']}: {d['name']} ({d['max_in']} in, {d['max_out']} out)")

if __name__ == "__main__":
    main()

実行結果:

 ALSA (arecord -l)
card 2, device 0: USB - USB Audio

 Python / sounddevice
index 1: Jabra SPEAK 510 USB: Audio (hw:2,0) (1 in, 2 out)
index 4: pulse (32 in, 32 out)
index 5: default (32 in, 32 out)

実行結果のPython / sounddevice以下にあるUSBマイク名を探してみてください。
次からのサンプルプログラム内では、このインデックス番号(#1)を指定しています。

処理を分岐するPythonプログラム

先程のJulius同様に、「停止」と「再開」という言葉で処理を分岐するプログラムをVOSKでも試してみます。
VOSKの場合は、Pythonと連携するのは比較的に容易でした。

「停止、ていし、テイシ」という言葉を待ち受ける仕様です。
なお、デバイスインデックス1、モノラル1chと指定しています。

※Crtl + Cで強制終了できます。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import sounddevice as sd
import queue
import json
from vosk import Model, KaldiRecognizer

MODEL_PATH = "model"
SAMPLE_RATE = 16000
q = queue.Queue()

def callback(indata, frames, time, status):
    if status:
        print(status, flush=True)
    q.put(bytes(indata))

def main():
    print("VOSKモデル読み込み中...")
    model = Model(MODEL_PATH)
    rec = KaldiRecognizer(model, SAMPLE_RATE)

    machine_running = True

    # USBマイクはカード1、デバイス0、モノラル
    with sd.RawInputStream(
            device=1,
            samplerate=SAMPLE_RATE,
            blocksize=8000,
            dtype="int16",
            channels=1,
            callback=callback):
        print("音声認識を開始しました。'停止' または '再開' と話してください。")
        while True:
            data = q.get()
            if rec.AcceptWaveform(data):
                result = json.loads(rec.Result())
                text = result.get("text", "")
                if text:
                    print(f"認識結果: {text}")
                    if any(word in text for word in ["停止", "ていし", "テイシ"]) and machine_running:
                        print(">>> 停止コマンド検出!")
                        machine_running = False
                    elif any(word in text for word in ["再開", "さいかい", "サイカイ"]) and not machine_running:
                        print(">>> 再開コマンド検出!")
                        machine_running = True

if __name__ == "__main__":
    main()

結果をみてみると、エラーこそありませんが、認識結果は「停止」がどうしても「天使」になってしまっています。

実行結果:

認識結果: 天使
認識結果: 天使
認識結果: 開始
認識結果: いや 天使
認識結果: 天使
認識結果: いい
認識結果: て いい し
認識結果: 天使
認識結果: いい し

これは小規模モデル(small)だから起こりえたことでもあり、マイクの種類や環境にも依存します。

特に高性能なマイクは指向性があり、音源の位置によっては認識の精度が変わってしまうことがあるからです。
または雑音が多い環境などでも認識の精度は落ちてしまいます。

VOSKでの精度を上げる方法として語彙補正をかけてみます。

VOSKは待ちたい単語を指定できます。→ rec = KaldiRecognizer(model, SAMPLE_RATE, '["停止", "再開"]')
こうすると、「停止」と「再開」以外の単語は無視されるため、精度が格段に改善されることがあります。

修正したPythonプログラム

最終的には次のようなコードで想定通りに動作しました。
何度か「停止」発言後に「再開」を発言し、一回で認識できているか、どのくらいのスピードで処理が停止するのかを確認してみてください。

smallモデルで約1秒後 (bigモデルで約2秒後)に停止や再開が表示されます。
あまりにも高い精度が要求されない事例であれば、一応は発言から「停止」できるシステムになりました。

※Crtl + Cで強制終了できます。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import sounddevice as sd
import queue
import json
from vosk import Model, KaldiRecognizer

# 設定
MODEL_PATH = "model"      # vosk-model-small-ja-0.22 を model/ に置く
SAMPLE_RATE = 16000       # 小モデルは 16kHz 推奨
q = queue.Queue()

# コマンド判定用関数
def stop_process():
    print(">>> 機械停止処理を実行")

def resume_process():
    print(">>> 機械再開処理を実行")

# 音声データコールバック
def callback(indata, frames, time, status):
    if status:
        print(status, flush=True)
    # RawInputStream の場合、indata は _cffi_backend.buffer
    # VOSK に渡すには bytes 型に変換
    q.put(bytes(indata))

def main():
    print("VOSKモデル読み込み中...")
    model = Model(MODEL_PATH)

    # 語彙補正で認識単語を限定(停止・再開)
    rec = KaldiRecognizer(model, SAMPLE_RATE, '["停止","再開"]')

    machine_running = True

    # USBマイクはカード1、モノラル1ch
    with sd.RawInputStream(
            device=1,           # USBマイクのカード番号
            samplerate=SAMPLE_RATE,
            blocksize=8000,
            dtype="int16",
            channels=1,         # モノラル1ch
            callback=callback):
        print("音声認識を開始しました。'停止' または '再開' と話してください。")

        while True:
            data = q.get()
            if rec.AcceptWaveform(data):
                result = json.loads(rec.Result())
                text = result.get("text", "")
                if text:
                    print(f"認識結果: {text}")
                    # 判定
                    if "停止" in text and machine_running:
                        stop_process()
                        machine_running = False
                    elif "再開" in text and not machine_running:
                        resume_process()
                        machine_running = True

if __name__ == "__main__":
    main()

実行結果:

音声認識を開始しました。'停止' または '再開' と話してください。
認識結果: 停止
>>> 機械停止処理を実行
認識結果: 停止
認識結果: 再開
>>> 機械再開処理を実行

認識結果の通り、停止と再開がどちらも認識されているのが分かります。
2回目の停止は、停止中に停止と認識しても、何も処理しない結果で間違っていません。

再開と話すことで、処理を実行する分岐も成功しています。応答速度は遅いとはいえ、エラーはなくスムーズです。

今回は「停止」「再開」といった短い単語でした。「天使」と間違ったりするのは、単純に利用した日本語認識モデルが小さいからです。

認識が怪しい場合は、単語をユニークにするためにも、「作業停止」といった他の単語と間違えにくい言葉を選ぶのも良いでしょう。
VOSKもカスタム辞書が扱えます。(やり方は変わりますがJuliusも辞書を持てます)

外部ファイルから単語リストを読み込むこともできますが、サンプルコードで決め打ちしてある箇所を、次のようにリストとして指定するのはお手軽です。

# 語彙補正で認識単語を限定(停止・再開)→ 文法リスト(フレーズ指定)
grammar = '["停止", "再開", "機械を停止して", "再開してください"]'

rec = KaldiRecognizer(model, SAMPLE_RATE, grammar)

今回は停止・再開はプリント文で表示しているだけですが、そこに何かしらの処理を追加して実行を確かめてみてください。

実用的な音声認識

音声認識でプログラム処理を分岐できるとなると、産業用途に限らず、複雑な操作をしなくても済むスイッチボタン代わりに扱うことができます。
追加でUSB接続マイクを用意すればよく、単語や一文などフレーズでシステムを操作できるのは実用的です。

Juliusはサーバーモードが便利です。プログラミングはやや複雑になりますが、何かと連携させるには最適に思えます。組み込む柔軟性が高いですね。
一方、VOSKはPython環境での扱いが容易です。初めからPython環境が整っているRaspberry Piに最適ですね。

どちらもRaspberry Pi で処理スペックは十分に感じました。
ある程度をRaspberry Pi側で担わせれば、省電力で本体サイズも小さいのが利点になります。

Julius:https://github.com/julius-speech/julius
VOSK:
https://alphacephei.com/vosk/models


記事寄稿:ラズパイダ

非エンジニアでも楽しく扱えるRaspberry Pi 情報サイト raspida.com を運営。ラズベリーパイに長年触れた経験をもとに、ラズベリーパイを知る人にも、これから始めたいと興味を持つ人にも参考になる情報・トピックを数多く発信。PiLinkのサイトへは産業用ラズベリーパイについて技術ブログ記事を寄稿。