speech-embeddingを使用した軽量ウェイクワード実装「local-wake」を試す
ここで知った。
DeepL翻訳
ウェイクワード検出(ユーザー定義フレーズ対応)
みなさん、こんにちは。ウェイクワード検出について時々議論されているのを見かけたので、最近作成したものを共有したいと思います。TLDR - https://github.com/st-matskevich/local-wake
私はRaspberry Pi上でMCP統合機能を備えたスマートアシスタントのプロジェクトを開始することにしました。しかし、まず最初にウェイクワード検出を実装する必要があります。既存のオープンソースソリューションは限られており、古典的なMFCC + DTW方式は精度が低く、ユーザーごとにチューニングが必要か、モデルベースのソリューションはMLの基礎知識が必要で、ユーザーがカスタムウェイクワードを設定できないという課題があります。
そこで、これらの2つのアプローチの利点を組み合わせ、独自のソリューションを実装しました。音声から音声特徴を抽出するためにGoogleのspeech-embeddingを使用しており、ノイズや声のトーンに強く、異なる話者の声にも対応可能です(別の人の声を入力として録音した参照セットでテスト済み)。その後、これらの特徴をDTWと比較することで、時間的なずれを回避しています。
現在のところ、私のアプローチの精度はいわゆるMFCC + DTWよりもはるかに優れており、Raspberry 4のCPU使用率は約25%に抑えられています。しかし驚くべきことに、このアプローチを採用している他の事例は(少なくとも現時点では)見当たりません。そのため、このアプローチを共有し、ご意見やご指摘をいただきたいと考えています。類似の試みをした方や、私が気づいていない明らかな問題点があれば、ご教示いただければ幸いです。
GitHubレポジトリ
GPT-5による翻訳
local-wake
軽量なウェイクワード検出システムで、Raspberry Pi のようなリソース制約のあるデバイス上でローカルに動作します。カスタムのウェイクワードに対応するためのモデル学習は不要で、エンドユーザーが自分のデバイス上で完全に設定可能です。本システムは、特徴量抽出と時間伸縮比較(タイムワーピング)を組み合わせ、ユーザー定義の参照セットと比較する仕組みに基づいています。
インストール
前提条件
- Python 3.8 以降
- pip(Python パッケージマネージャ)
- 音声入力デバイス(例: マイク)
実装
既存のウェイクワード検出ソリューションは、一般的に次の2つのカテゴリーに分類されます:
- 古典的な決定論的・話者依存型アプローチ
通常、MFCC 特徴量抽出と DTW を組み合わせた方式で、Rhasspy Raven や Snips のようなプロジェクトで採用されています。
- 利点: ユーザー定義のウェイクワードを最小限の開発労力でサポート可能。
- 制限: 強く話者依存であり、すべての対象ユーザーからのサンプル収集が必要。環境ノイズに非常に敏感。
- 現代的なモデルベース・話者非依存型アプローチ
ニューラルモデルを使用して直接ウェイクワードを分類する方式で、openWakeWord や Porcupine などがあります。
- 利点: 追加のサンプル収集なしで複数話者間で高精度を達成可能。
- 制限: 任意のユーザー定義ウェイクワードには対応できない。製品固有のウェイクワード対応にはモデルの再学習やファインチューニングが必要で、場合によっては複雑であり、機械学習の基本的な知識やデータセット準備が求められる。
どちらのカテゴリーも制約があります。決定論的方法は堅牢性を犠牲にし、モデルベースの方法は適応性を犠牲にします。
local-wake は、ニューラル特徴量抽出と古典的なシーケンスマッチングを組み合わせ、柔軟かつ堅牢なウェイクワード検出を実現します。Google の事前学習済み speech-embedding モデルを用いて音声特徴量を抽出し、その後 Dynamic Time Warping を適用して、入力音声とユーザー定義のウェイクワード参照セットを比較します。
このアプローチは、前述の両カテゴリーの利点を統合しています。従来型の決定論的方法のようにユーザー定義ウェイクワードをサポートしつつ、ニューラルモデルによる高度な特徴表現とノイズ耐性を活用します。その結果、大規模なモデル学習や大量のデータセットを必要とせずに、高精度かつ柔軟なシステムを実現します。
今後の課題
- CPU 使用率削減のため、高速な DTW 実装の採用を検討
- DTW の代わりに特徴量抽出上に小規模モデルを使用した比較を検討
- 音声前処理に VAD の利用を検討
- 音声前処理にノイズ抑制の利用を検討
- 精度テストの実施
tensorflow_hub
を削除し、モデルをローカルファイルから読み込むことで完全オフライン使用を可能にする使用技術
ライセンス
MIT ライセンスの下で配布されています。詳細は LICENSE を参照してください。
インストール
Raspberry Piが挙げられているので、実際にRaspberry Pi 4B上で試してみる。
レポジトリクローン
git clone https://github.com/st-matskevich/local-wake && cd local-wake
uvでPython仮想環境を作成
uv init -p 3.12
パッケージインストール
uv add tensorflow tensorflow-hub librosa sounddevice soundfile numpy
(snip)
+ librosa==0.11.0
(snip)
+ numpy==2.1.3
(snip)
+ sounddevice==0.5.2
+ soundfile==0.13.1
(snip)
+ tensorflow==2.19.0
+ tensorflow-hub==0.16.1
(snip)
sounddevice が使用されているので、OS側でPortAudioライブラリがインストールされている必要がある。自分の環境ではすでにインストールされていた。
sudo apt install libportaudio2
使い方
まずリファレンスとなる音声データを用意する。通常は3〜4個のサンプルで良いみたい。これを録音するためのスクリプトも用意されている。
mkdir ref
uv run record.py ref/sample-1.wav --duration 3
以下のように表示されたら登録したいウェイクワードを発話する。今回は「ラズパイ」というウェイクワードにする。
INFO:local-wake:Recording for 3 seconds...
録音された。
INFO:local-wake:Saved to ref/sample-1.wav
実際に確認して録音できていればOK。
aplay work/sample-1.wav
同様にして合計4サンプル作成。少し発話の仕方などを意図的に変えて用意した。
uv run record.py ref/sample-2.wav --duration 3
uv run record.py ref/sample-3wav --duration 3
uv run record.py ref/sample-4.wav --duration 3
では compare.py
を使って、音声を比較してみる。
uv run compare.py ref/sample-1.wav ref/sample-2.wav
初回はモデルがダウンロードされる。諸々Warningも出たがとりあえず無視。
INFO:local-wake:Comparing ref/sample-1.wav and ref/sample-2.wav using embedding features...
INFO:local-wake:Loading speech embedding model...
INFO:absl:Using /tmp/tfhub_modules to cache modules.
INFO:absl:Downloading TF-Hub Module 'https://tfhub.dev/google/speech_embedding/1'.
INFO:absl:Downloaded https://tfhub.dev/google/speech_embedding/1, Total size: 2.66MB
INFO:absl:Downloaded TF-Hub Module 'https://tfhub.dev/google/speech_embedding/1'.
INFO:absl:Fingerprint not found. Saved model loading will continue.
INFO:absl:path_and_singleprint metric could not be logged. Saved model loading will continue.
(snip)
以下のように音声ファイル間のコサイン距離が出力される。
INFO:local-wake:Model loaded successfully
INFO:absl:Using /tmp/tfhub_modules to cache modules.
INFO:absl:Fingerprint not found. Saved model loading will continue.
INFO:absl:path_and_singleprint metric could not be logged. Saved model loading will continue.
INFO:local-wake:Model loaded successfully
INFO:local-wake:Features 1 shape: (96, 28)
INFO:local-wake:Features 2 shape: (96, 28)
INFO:local-wake:Normalized DTW distance (cosine): 0.0330
一通り4ファイルすべてのコサイン距離を確認してみた。
# uv run compare.py ref/sample-1.wav ref/sample-3.wav
INFO:local-wake:Normalized DTW distance (cosine): 0.0347
# uv run compare.py ref/sample-1.wav ref/sample-4.wav
INFO:local-wake:Normalized DTW distance (cosine): 0.0595
# uv run compare.py ref/sample-2.wav ref/sample-3.wav
INFO:local-wake:Normalized DTW distance (cosine): 0.0263
# uv run compare.py ref/sample-2.wav ref/sample-4.wav
INFO:local-wake:Normalized DTW distance (cosine): 0.0470
# uv run compare.py ref/sample-3.wav ref/sample-4.wav
INFO:local-wake:Normalized DTW distance (cosine): 0.0504
これらの結果から、しきい値を設定すればよいということらしい。
ではマイクを使ったリアルタイムな検出を試してみる。listen.py
に リファレンス音声のディレクトリとしきい値を渡して実行。しきい値は上記の結果からは0.06
にしてみた。
uv run listen.py ref 0.06
INFO:local-wake:Loading support set using embedding features...
INFO:local-wake:Loading reference file: sample-3.wav
INFO:local-wake:Loading speech embedding model...
INFO:absl:Using /tmp/tfhub_modules to cache modules.
INFO:absl:Fingerprint not found. Saved model loading will continue.
INFO:absl:path_and_singleprint metric could not be logged. Saved model loading will continue.
INFO:local-wake:Model loaded successfully
INFO:local-wake:Features shape: (96, 28)
INFO:local-wake:Loading reference file: sample-2.wav
INFO:local-wake:Features shape: (96, 28)
INFO:local-wake:Loading reference file: sample-1.wav
INFO:local-wake:Features shape: (96, 28)
INFO:local-wake:Loading reference file: sample-4.wav
INFO:local-wake:Features shape: (96, 28)
INFO:local-wake:Loaded 4 reference files
INFO:local-wake:Starting audio stream (buffer: 2.0s, slide: 0.25s)
INFO:local-wake:Using embedding features with threshold 0.1
INFO:local-wake:Listening for wake words...
発話してみるとこうなる。
INFO:local-wake:Wake word 'sample-3.wav' detected with distance 0.0313
{"timestamp": 1754962952170, "wakeword": "sample-3.wav", "distance": 0.0313216334706702}
INFO:local-wake:Wake word 'sample-2.wav' detected with distance 0.0380
{"timestamp": 1754962952170, "wakeword": "sample-2.wav", "distance": 0.03803966027275265}
INFO:local-wake:Wake word 'sample-1.wav' detected with distance 0.0572
{"timestamp": 1754962952170, "wakeword": "sample-1.wav", "distance": 0.057190398629153115}
INFO:local-wake:Wake word 'sample-3.wav' detected with distance 0.0304
{"timestamp": 1754962952426, "wakeword": "sample-3.wav", "distance": 0.030408034287895694}
INFO:local-wake:Wake word 'sample-2.wav' detected with distance 0.0277
{"timestamp": 1754962952426, "wakeword": "sample-2.wav", "distance": 0.027705023220690583}
INFO:local-wake:Wake word 'sample-1.wav' detected with distance 0.0405
{"timestamp": 1754962952426, "wakeword": "sample-1.wav", "distance": 0.04047438631853155}
INFO:local-wake:Wake word 'sample-2.wav' detected with distance 0.0462
{"timestamp": 1754962952654, "wakeword": "sample-2.wav", "distance": 0.0461812787603395}
INFO:local-wake:Wake word 'sample-1.wav' detected with distance 0.0524
{"timestamp": 1754962952654, "wakeword": "sample-1.wav", "distance": 0.05239601539626877}
発話の仕方を変えて試してみると良い。その他、バッファサイズのオプションなどもあるのでREADMEを参考にいろいろ調整してみると良い。
その他
少し試してみた限りの補足。
しきい値の設定
しきい値は、リファレンスデータに合わせて調整が必要。
例えば、今回の音声データの場合だと
- ドキュメントと同じ
0.1
にすると、ほぼほぼずっと認識しているような感じになった。 - リファレンス音声から算出した
0.06
にすると、いい感じに認識してくれたが、少し言い方を変えたりすると認識しなかった。
という感じになったので、多少試行錯誤は必要かも。
ただし、今回は無音カットしていない音声で試しているため、無音カットすれば結果はまた変わるかもしれない。
無音カット
ということでリファレンスファイルから無音部分をカットする。READMEにもこういう注意書きがある。
録音スクリプトは比較的シンプルで、デフォルトの3秒間の録音中にウェイクワードの前後で無音や背景ノイズが記録される可能性があります。検出精度を向上させるためには、録音データをウェイクワードのセグメントのみにトリミングすることをおすすめします。これらは、オーディオ編集ソフトウェアを手動で操作するか、Voice Activity Detection(VAD)ツールを使用して自動的に行うことができます。
まあそれはそうだよね、ということで、無音カットした音声を用意して(Audacityで手動で発話前後の無音をカット)、まず比較してみたのだが、
# 無音カットした音声ファイルは ref-silence-trimmed/sample-{1,2,3,4}.wav としている
uv run compare.py ref-silence-trimmed/sample-1.wav ref-silence-trimmed/sample-2.wav
2025-08-12 10:55:30.498835: W tensorflow/core/framework/op_kernel.cc:1857] OP_REQUIRES failed at conv_ops_impl.h:668 : INVALID_ARGUMENT: Computed output size would be negative: -1 [input_size: 1, effective_filter_size: 3, stride: 1]
2025-08-12 10:55:30.498957: I tensorflow/core/framework/local_rendezvous.cc:407] Local rendezvous is aborting with status: INVALID_ARGUMENT: Computed output size would be negative: -1 [input_size: 1, effective_filter_size: 3, stride: 1]
[[{{node embeddings_apply_default/MudpuppyLite/Conv_18/Conv2D}}]]
(snip)
tensorflow.python.framework.errors_impl.InvalidArgumentError: Graph execution error:
Detected at node embeddings_apply_default/MudpuppyLite/Conv_18/Conv2D defined at (most recent call last):
(snip)
Computed output size would be negative: -1 [input_size: 1, effective_filter_size: 3, stride: 1]
[[{{node embeddings_apply_default/MudpuppyLite/Conv_18/Conv2D}}]] [Op:__inference_pruned_4530]
とか、
uv run compare.py ref-silence-trimmed/sample-1.wav ref-silence-trimmed/sample-4.wav
ValueError: cannot reshape array of size 0 into shape (0,newaxis)
とか、いろいろエラーになる。
GPT-5にエラーを投げて、いろいろ調べてもらった感じだと、どうやら無音カットなどであまりに音声長が短くなりすぎると、埋め込みモデルの内部計算が破綻する場合があるみたい。ざっくりこんな感じにすると良さそう。
- リファレンスデータの無音部分をカットしすぎない。前後に多少の無音(50ms程度?)を残しておく。
- 全てのリファレンスデータの音声長を揃えておく。
- ウェイクワードであれば、まあ1秒程度に揃えれば良さそう。
- 揃える際は、末尾を無音でパディングすればよさそう。
自分の場合はこんな感じで、末尾に無音を追加して長さを1秒に揃えた。
mkdir ref-fixed/
for f in ref-silence-trimmed/*.wav; do
ffmpeg -i "$f" -af "apad=pad_dur=1" -t 1.0 "ref-fixed/$(basename "$f")"
done
全通りで比較してみる。
files=(ref-fixed/*.wav)
for ((i=0;i<${#files[@]};i++)); do
for ((j=i+1;j<${#files[@]};j++)); do
f1="${files[i]}"; f2="${files[j]}"
echo "=== $f1 vs $f2 ==="
uv run compare.py "$f1" "$f2" --method embedding 2>&1 | grep "Normalized DTW distance"
done
done
エラーは出なくなった。
=== ref-fixed/sample-1.wav vs ref-fixed/sample-2.wav ===
INFO:local-wake:Normalized DTW distance (cosine): 0.1544
=== ref-fixed/sample-1.wav vs ref-fixed/sample-3.wav ===
INFO:local-wake:Normalized DTW distance (cosine): 0.0919
=== ref-fixed/sample-1.wav vs ref-fixed/sample-4.wav ===
INFO:local-wake:Normalized DTW distance (cosine): 0.1270
=== ref-fixed/sample-2.wav vs ref-fixed/sample-3.wav ===
INFO:local-wake:Normalized DTW distance (cosine): 0.0381
=== ref-fixed/sample-2.wav vs ref-fixed/sample-4.wav ===
INFO:local-wake:Normalized DTW distance (cosine): 0.1099
=== ref-fixed/sample-3.wav vs ref-fixed/sample-4.wav ===
INFO:local-wake:Normalized DTW distance (cosine): 0.0762
listen.py
で認識させる場合、バッファサイズ(--buffer-size
)はこのリファレンス長+αぐらいに設定するのが良いみたい。
uv run listen.py ref-fixed 0.16 --buffer-size 1.2
余談だが、上記のコサイン距離の組み合わせ結果を見るとばらつきが多い=リファレンス音声の差が大きい(sample-1が他との違いが一番大きい)ので、しきい値の設定が難しくなる(0.16
にあわせるとほぼずっと検出中になってしまう)。このあたりはリファレンス音声自体を再度録音し直すなどの調整も必要になりそう。
なんとなくだけど、無音カットしていろいろ調整が必要になるぐらいなら、最初から短い時間でリファレンス音声を録音して、問題なさそうならそのまま使うほうが楽なんじゃないかなと思ったりも。
軽量化を謳っているので、一応CPU使用率も確認してみた。自分の環境だとプロセス自体は50%ぐらいに見える。
0[|||||||| 17.9%] Tasks: 50, 154 thr; 1 running
1[|||||||||| 23.2%] Load average: 0.62 0.35 0.23
2[|||||||| 16.9%] Uptime: 49 days, 22:19:41
3[|||||||| 18.6%]
Mem[|||||||||||||||||||||||||||||987M/7.62G]
Swp[ 0K/0K]
PID USER PRI NI VIRT RES SHR S CPU%▽MEM% TIME+ Command
325915 kun432 20 0 2569M 700M 354M S 54.1 9.0 0:34.60 /home/kun432/local-wake/.venv/bin
(snip)
このあたりはRedditで書いてあるよりは食ってるように思えるが、自分は未だにtopとはhtopの出力の意味を理解してないのよな。慣れてるsarで確認してみるかな・・・
いずれにせよ今後さらにCPU削減を目指すようなので期待したいところ。
まとめ
試した感じ、悪くなさそう。ただ、誤判定も誤検出もあるので、リファレンス音声の作り方やしきい値の設定などの試行錯誤は必要だと思うし、アプリに組み込む場合場合でも判定ロジックは別途実装が必要な気がする。
ところで、speech-embeddingとか全然知らなかった。作者の方も書いているが、Embeddingを使ったウェイクワード検出のアプローチってのはありそうなものだけど、実際にはないのかな?ウェイクワードがニッチ過ぎて、誰もやってない、って可能性はあるかもだけど。
とりあえず軽量を目的になっているようなので、今後も期待したい。
いろいろ調べてみたけども
- カスタムなウェイクワードが学習不要(必要なのはリファレンス音声だけ)
- 軽量・ローカルで動作
- ウェイクワードの話者非依存
って条件を満たすものはなさそう。かなりユニークなのかも。
このあたりはRedditで書いてあるよりは食ってるように思えるが、自分は未だにtopとはhtopの出力の意味を理解してないのよな。慣れてるsarで確認してみるかな・・・
sarでコアごとに計測し直してみた
sudo apt install -y sysstat
sar -u -P ALL 1 -1
uv run listen.py ref 0.06
sarの出力
00:28:57 CPU %user %nice %system %iowait %steal %idle
00:28:58 all 14.72 0.00 2.03 0.00 0.00 83.25
00:28:58 0 19.79 0.00 2.08 0.00 0.00 78.12
00:28:58 1 12.87 0.00 2.97 0.00 0.00 84.16
00:28:58 2 13.27 0.00 1.02 0.00 0.00 85.71
00:28:58 3 13.13 0.00 2.02 0.00 0.00 84.85
00:28:58 CPU %user %nice %system %iowait %steal %idle
00:28:59 all 13.27 0.00 3.57 0.00 0.00 83.16
00:28:59 0 15.46 0.00 6.19 0.00 0.00 78.35
00:28:59 1 11.34 0.00 2.06 0.00 0.00 86.60
00:28:59 2 14.14 0.00 2.02 0.00 0.00 83.84
00:28:59 3 12.12 0.00 4.04 0.00 0.00 83.84
00:28:59 CPU %user %nice %system %iowait %steal %idle
00:29:00 all 12.85 0.00 4.53 0.00 0.00 82.62
00:29:00 0 12.00 0.00 12.00 0.00 0.00 76.00
00:29:00 1 12.37 0.00 1.03 0.00 0.00 86.60
00:29:00 2 14.71 0.00 2.94 0.00 0.00 82.35
00:29:00 3 12.24 0.00 2.04 0.00 0.00 85.71
00:29:00 CPU %user %nice %system %iowait %steal %idle
00:29:01 all 14.29 0.00 1.79 0.00 0.00 83.93
00:29:01 0 20.41 0.00 2.04 0.00 0.00 77.55
00:29:01 1 12.12 0.00 2.02 0.00 0.00 85.86
00:29:01 2 11.46 0.00 1.04 0.00 0.00 87.50
00:29:01 3 13.13 0.00 2.02 0.00 0.00 84.85
確かにCPU使用率は低いかもしれないな。適当なツールを適当な理解で使っちゃダメだな、反省。
古い記事だけどウェイクワードの基本的な仕組みなどが説明されている(なお、記事は元々はウェイクワードを事業としてやっていた企業のものだが、その後大手に買収された様子。ウェイクワードのトレーニングのチュートリアルなどへのリンクもあるが、一部辿れないものがあったように思う。)