UMEHOSHI ITA TOP PAGE

作成中

[Raspberry Pi 3 Model A+]と[UMEHOSHI ITA]を乗せたモータ付き台車の利用例: 音声入力での操作

このページで示したロボットのハードを使っています。
なお、EEPROMの内容は、この
ページで作成した内容と同じです。
(別途のRAMにSPI受信処理を埋め込む方式で検討しています)

RaspberryPi3で動かす音声認識の検討

現時点(2026年4月)で、無料枠が多い音声認識サービスのIBM Watson Speech to Textが魅力的であるが、クレカ登録あったのでチョット足踏みしてしまった。
その必要が無くローカル実行が可能なOpenAI Whisperがあったが使用予定のRaspberryPi3では重いらしい。(Pi 4/5推奨)
そこでVosk(ヴォスク)というモジュールを利用して試すことにした。

まずは、OSを最新の状態にし、マイク入力に必要なライブラリをインストールします。
sudo apt update
sudo apt upgrade
sudo apt install portaudio19-dev
現在はPython 3.9.2 (default, Mar 12 2021, 04:06:34)で動作しています。(Raspberry Pi OS :Bullseye の標準環境)
このシステムへ、次の操作でVoskをインストールしました。
依存関係で、(C言語のインターフェースなど)を先にインストールしています。
sudo apt install python3-pip python3-cffi libportaudio2
pip3 install vosk
pip install pyaudio
以上で、srt-3.5.3 tqdm-4.67.3 vosk-0.3.45 websockets-15.0.1、pyaudio-0.2.14がインストールされました。

pip3 install vosk を実行しただけでは、モデル(学習済みデータ)はインストールされません。
あくまで Vosk を動かすための 「エンジン(プログラム本体)」 だけです。
言葉を理解するための「辞書(モデル)」は、別途手動でダウンロードして配置する必要があります。
「辞書(モデル)」は言語(日本語、英語、中国語など)やサイズ(軽量版、高精度版)によって多種多様です。
ラズパイ3では、メモリ消費が少ない「small」モデルを使用します。
Vosk Models へアクセスして、vosk-model-small-ja-0.22.zip(49,704,573byte)をダウンロードし、
次のように/usr/local/appsで、modelの名前のディレクトリへ展開しました。
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

以上で環境の出来上りです。 voskの実験に使うテスト用ファイル(vosk_wav_test.py)を次の様に用意しました。
import wave
import json
from vosk import Model, KaldiRecognizer

# 1. モデルのパスを指定(前回ダウンロードしたフォルダ名)
model_path = "model" 
# 2. 読み込むWAVファイル
wav_file = "morter_ctrl.wav"

if not wave.open(wav_file):
    print("WAVファイルが開けません")
    exit(1)

wf = wave.open(wav_file, "rb")

# フォーマットチェック
if wf.getnchannels() != 1 or wf.getsampwidth() != 2 or wf.getcomptype() != "NONE":
    print("WAVファイルは '16-bit PCM mono' である必要があります。")
    exit(1)

model = Model(model_path)
# サンプリングレートをファイルに合わせる
rec = KaldiRecognizer(model, wf.getframerate())

print("認識中...")

while True:
    data = wf.readframes(4000)
    if len(data) == 0:
        break
    if rec.AcceptWaveform(data):
        # 認識確定時の結果
        result = json.loads(rec.Result())
        print("確定結果:", result.get("text", ""))

# 最終的な認識結果
final_result = json.loads(rec.FinalResult())
print("最終結果:", final_result.get("text", ""))
このファイル(vosk_wav_test.py)と、認識させるテスト用の音声ファイル(morter_ctrl.wav)を次のように配置させて、 実行させます。
/usr/local/apps
├── vosk_wav_test.py  (実行するプログラム)
├── morter_ctrl.wav   (音声ファイル)
└── model/           
        ├── am/
        ├── conf/
        ├── graph/
        ├── ivector/
        └── rescore/
この実行結果は、次のようになりました。
suzuki@raspberrypi:/usr/local/apps $ python vosk_wav_test.py
LOG (VoskAPI:ReadDataFiles():model.cc:213) Decoding params beam=13 max-active=7000 lattice-beam=4
LOG (VoskAPI:ReadDataFiles():model.cc:216) Silence phones 1:2:3:4:5:6:7:8:9:10
LOG (VoskAPI:RemoveOrphanNodes():nnet-nnet.cc:948) Removed 0 orphan nodes.
LOG (VoskAPI:RemoveOrphanComponents():nnet-nnet.cc:847) Removing 0 orphan components.
LOG (VoskAPI:ReadDataFiles():model.cc:248) Loading i-vector extractor from model/ivector/final.ie
LOG (VoskAPI:ComputeDerivedVars():ivector-extractor.cc:183) Computing derived variables for iVector extractor
LOG (VoskAPI:ComputeDerivedVars():ivector-extractor.cc:204) Done.
LOG (VoskAPI:ReadDataFiles():model.cc:282) Loading HCL and G from model/graph/HCLr.fst model/graph/Gr.fst
LOG (VoskAPI:ReadDataFiles():model.cc:308) Loading winfo model/graph/phones/word_boundary.int
認識中...
確定結果: 全身 更新 左 回転 右 回転 同率
最終結果:
suzuki@raspberrypi:/usr/local/apps $
上記実行で使った、morter_ctrl.wav は、705Kbps モノラル 44.1KHz 16ビットサンプル で、 「前進 後進 左回転 右回転 倒立」と発音したの10秒の音声ファイルです。
確定結果は、認識中が出てから、約23秒後に表示されました。

Smallの辞書モデルの場合、高度な文脈判断でなく、直前の1?2単語とのつながりから判断なので、前進を全身と判断されるなど、希望の認識にならない。
そこでKaldiRecognizerの第3引数に、'["前進", "後進", "左回転", "右回転", "倒立", "[unk]"]'と 指定し、ワード 以外の判定をさせないようにした。
それは、上記 rec = KaldiRecognizer(model, wf.getframerate()) のコードを、次のように変更して実現しました。
# 認識させたい言葉のリストをJSON形式の文字列で作成
words = '["前進", "後進", "左", "右","回転", "倒立", "[unk]"]'
rec = KaldiRecognizer(model, wf.getframerate(), words)
10秒程度のファイルでは長すぎるので、1秒程度ファイルで判定することにした。
Voskで使う認識用も音声ファイルは、16bit LPCM (Little Endian)、1ch (モノラル)、16,000Hz (16kHz)のサンプリングレート限定に決定した。
(なお、Vosk内部でも結局 16kHzに間引く計算を行っているらしい。)

PIC32MX側で得たサウンド情報でRaspberryPi3を動かす検討

PIC32MX側のAN0のADコンバーター側を利用しました。
メモリが少ないので、バッファは使わずにすぐSPIを介して送信する方法で進めました。
Raspberry Piシリーズ(RP2040のPICO以外)は、基本的に「SPIスレーブ非対応」ということで、ラズパイがSPIマスターにします。
「二輪倒立制御を考える」で使ったようにラズパイがSPIマスターの接続を使いますが、 伝達方向が逆でPIC32MXからラズパイへ送信します。
1秒程度分の有効データがバッファに蓄えられたらrec1.wavファイルを生成して、それを音声認識を行う目標としました。
最終的、16KHzのサンプリングレートの16ビットサウンドデータを受信処理を続ける処理をRaspberry Piで行います。
SPIにおけるbyte連続受信において、SPIのクロックが16個で2byteの受信ができる。
16KHz、16bitのサウンドの生データを受信させる場合、RaspberryPi3でPythonのspi.max_speed_hzのSPIクロック周波数設定値は、それから逆算して 16KHz×16=256KHzに近い値を目標にしました。
なお、スレーブ側の受信処理は割り込み内で送信データを設定します。そのため、クロック周波数が大き過ぎると割り込み頻度が多すぎ不適切と考えて256KHzにします。
(ラズパイはSPIマスターにしかできないので、ラズパイ側は連続ダミー送信(16KHz×16のクロック)でデータを受信すれば良いと考えます。)
Raspberry PiのSPIクロックは、ベースとなる「コアクロック」を「偶数の整数」で割った値(分周)として生成されます。
そして、spi.max_speed_hzの設定値から近い分周値が決まって、それでSPIクロック周波数が決まります。
つまり、Raspberry PiのSPIクロックは「偶数の整数」で割った値しか出せないため、設定値通りの周波数になるとは限りません。
この設定ルールは、 指定した max_speed_hz を超えない範囲で、最も近い偶数の分周比が自動的に選ばれます。
Raspberry Pi 3 (BCM2837)の コアクロック 250MHzとすると次のようなSPIクロック値に換算できます。
spi.max_speed_hzの設定値左値で設定される分周比実際のSPI周波数換算したサンプリング周波数
256000978250MHz/978=約255623Hz約255623Hz/16=約15.976KHzで
16KHzの目標より約4%ほど遅い
256148976250MHz/976=約256147Hz約256147Hz/16=約16.009KHzで
16KHzの目標よりチョット速い
300000834250MHz/834=約299760Hz約299760Hz/16=約18.735KHzで
16KHzの目標から余裕がある速さ

以上より、SPIクロック周波数を、目標よりチョットだけ速い約16.009KHzにする設定(spi.max_speed_hz=256148)にします。

さて、Raspberry PiのPython(spidev)で spi.xfer2() などを繰り返す実行すると、数ミリ秒単位の「バラツキ(ジッタ)」が生じ、 「データ間に謎の隙間」できてガタガタになって、希望の音声データに復元できないようです。
この隙間が無い受信を行うためには、まとまったデータをspi.xfer2()で一括に送信(一括に同時受信)する方法があります。
spi.xfer2 の仕様では、リストの全データを送り終えるまで CS(チップセレクト)は LOW のまま保持され、その間に隙間ができません。

1秒程度音声ファイルの命令判定を考えているので、このまとまったデータのリストサイズは、
1秒/(16KHの周期)×1ワード=32000個となり、この個数のbyteリストをspi.xfer2()で送れば、この中で隙間ができない受信を得られことになります。
dummy_data = [0, 1] * 16000 
# 一括送信して受信データを得る
received_data = spi.xfer2(dummy_data)# この間、ハードウェアが連続してクロックを出し続ける
ですが、Raspberry PiのデフォルトのSPIバッファサイズは 4,096バイト です。
そこで、/boot/cmdline.txt に spidev.bufsiz=32768 のように記述して上限を増やします。
(変更には再起動が必要で、そのサイズは cat /sys/module/spidev/parameters/bufsiz で確認できます。)


このように ラズパイ側の16KHzのクロックで、1秒の32000byteをspi.xfer2で一括に送受信するよう決めました。
対してPIC32MX側はSPIのスレーブでは、1バイトの受信割り込み毎にADコンバータのデータを受信と同時に送信する形式です。
つまり、実際のSPI周波数:256147Hzの周期=約3.9u秒ごとの割り込み処理で、ADコーバーのデータをセットする処理にします。
ワードとして同期を取るために、ラズパイかの送信データを0と1のデータを送ることにします。
対して、PIC32MX側のSPスレーブ受信割り込みでは、0の受信時にADCの上位バイト、1の受信時に下位1バイトを送信させます。
以上で、1秒程度の音声のワードブロックを隙間なく伝達でます。

さて、これでも問題が残っています。
それは1秒間のブロック受信の始まりをPIC32MX側からの送信タイミングに合わせないと、1秒間のブロック受信が部分的に失敗するということです。
そこで、PIC32MX側は符号無し16ビットですが、0のワードデータをデータ無しのフラグデータと決めます。
つまり、これが0以外になった直後で、32000byteのブロック受信を行うようにします。
よってPIC32MX側はマイクからの入力有無を判断して、無ければ0を送信する制御を行うようにします。
この場合でも、ラズパイ側の0以外のワード受信直後に、処理の隙間が出来る受信ミスを可能性は避けれませんが、ミスの頻度を減らせて、ミスの判定も可能にできます。

以上の手順の受信を考慮したラズパイ側の受信スレッド処理は次のイメージです。
def recive_loop(spi: spidev.SpiDev): # SPI 送受信スレッド用関数
   start_flag = True
   block = [0, 1] * 16000 # 約1秒16ビットの音声用ばバッファ
   while True:
      if start_flag :
         received_data0 = spi.xfer3( [0, 1] ) # 先頭データ受信
         if received_data0[0] == 0 and received_data0[1] == 0:
            start_flag = False
      else:
         received_data = spi.xfer3( block ) # 約1秒間受信
         tart_flag = True
         block = [0, 1] * 16000
         print(received_data0 + received_data)
         #このデータを補正後に.wavファイルを生成し、Voskに音声認識させ、応答でロボットを動かす予定;
         break
   #

spi = spidev.SpiDev() # SPI操作オブジェクト生成
spi.open(0, 0)        # bus=0 device=0 (CE0) でSPIオープン

spi.max_speed_hz = 256148 # 約256147Hz = 16.009KHz*16 SPIクロック
spi.mode = 0 # クロック信号(SCLK)はLow待機、LowからHighに立ち上がる瞬間でデータを読み取り

なお、SPIで一度に送れるサイズは でファイルで、4096バイト までに制限されています。
このサイスを変更しても、spi.xfer2( block )を使うと、
verflowError: Argument list size exceeds 4096 bytes.のエラーが出ます。
この xfer2メソッドでは4096byteが限界であるためから生じるもので、xfer3メソッドを使えば問題なく実行できました。
xfer3 は OS が許容する最大バッファサイズ(65536バイト)まで CS ピンをアクティブ(通常は LOW)に保ち、 途切れなく送受信できる。
(xfer3 は、65536 バイト(64KB)を超えるデータを一度に送ろうとした場合、
 データを 64KB ごとに分割します。そして 分割された送受信の間で一度 CS が HIGH に戻り、わずかな瞬断が発生します。

次に上記の実行で SPIの受信でint型のリストが得られ、その received_data0+received_data のリストから.wavファイルを作ります。
PIC32MXのADCの分解能は10bitですが、16bitの符号なしを、このリストに2つのint要素に記憶する構造でする。
16bit LPCM (Little Endian)、1ch (モノラル)、16,000Hz (16kHz)のサンプリングレートのファイルを生成する関数を次のように作り、 それを利用してWAVファイルするmake_wave_file関数の定義と検証するmakewav.pyを以下に示します。
#!/usr/bin/python3
# 『/usr/local/apps/makewav.py』の内容

# 16bit 16KHz の wavファイルを生成する
import wave
def make_wave_file( int_list, path="test.wav"):
    ''' int_list の10bitリトルエンディアン符号なし列から、16bit 16KHzのサウンドファイルを作る'''
    barray = bytearray()
    for i in range(0,len(int_list), 2):
        v = int_list[i] + (int_list[i+1] << 8)
        signed_16bit = (v << 6) - 32768 # 16bitに拡大して、中央値を引いて符号付きにする

        # リトルエンディアン(下位バイト -> 上位バイト)で格納
        barray.append(signed_16bit & 0x00FF)  # 下位バイト
        barray.append((signed_16bit >> 8) & 0x00FF)  # 上位バイト
    #
    wavefile = wave.open(path, 'wb') # waveファイルをバイナリ保存用として開く。
    wavefile.setnchannels(1) # モノラル(単一の音信号)
    wavefile.setsampwidth(2) # 2byteのデータ群と録音する指定
    wavefile.setframerate(16000) # サンプリングレート 16Hkz
    wavefile.writeframes(barray) # 上記で設定した属性で、ファイルに出力
    wavefile.close()

if __name__ == "__main__": 
    import matplotlib.pyplot as plt
    plot_y_list = [] # グラフ表示用のYデータリスト
    plot_x_list = []
    int_list=[] # int_listは、ADCからの生データの代わりのデータリスト
    # ADCは符号無し10bitでリトルエンディアンで得られるので。その検証用ダミーデータでwavファイル生成を試す
    v=0
    for x in range(16000):
        int_list.append( v & 0x0ff ) # 下位バイト
        int_list.append( (v & 0x0ff00) >> 8 ) # 上位バイト
        print(v)
        plot_y_list.append(v)
        plot_x_list.append(x)
        v+=8
        if v > 0x3ff: v=1

    plt.plot(plot_x_list, plot_y_list) # プロット表示
    plt.show()
    make_wave_file( int_list , "morter_ctrl.wav")