背景
最近リモートワークをしがちなのですが、便利な一方で、「ちょっとした雑談」や「声に出して相談しながら考えを整理する時間」が、ほとんどなくなってしまいました。作業に詰まったとき、隣の人に話しかけているうちに自分で答えに気づく、ということがよくありましたが、一人で黙々と作業しているとそのきっかけがありません。
そこで、前から試してみたかった Raspberry Pi 5 でのローカルLLMと組み合わせて、机の上にいる、気軽に話しかけられる相棒を作ってみることにしました。
この記事では、その相棒「ラズ」ができるまでと、作ってみて分かったことを紹介します。
概要
作ったのは、「ヘイ、ラズ」と呼びかけると返事をして、語尾に「〜だっぴ」をつけて話す音声アシスタント「ラズ」です。
会話はファイルに保存されるので、ラズパイを再起動しても前に話したことを覚えています。
| 話しかけ方 | ラズの動き |
|---|---|
| 「ヘイ、ラズ」 | 「なんだっぴ?」と返事をして質問を待つ |
| 「ヘイ、ラズ、〇〇は?」 | 続けて言った質問にそのまま答える |
| 「ヘイ、ラズ、調子は?」 | CPU温度やメモリ、ファンの回転数を調べて、自分の体調として答える |
| 「ヘイ、ラズ、記憶を消して」 | 覚えたことを全部忘れる |
使ったもの (ハードウェア)
以下のものを使用しました。
| もの | 用途・メモ |
|---|---|
| Raspberry Pi 5 | 本体。LLMを動かすのでメモリは8GB以上がおすすめ |
| 公式 Active Cooler(ファン) | 必須。LLMの推論中はCPUがかなり熱くなる |
| 公式電源(5V/5A, 27W) | 一般的なUSB-C充電器だと電力不足で起動しない |
| USB小型マイク | 声の入力。USB 2.0ポートに接続 |
| モニター(スピーカー内蔵) | 画面表示と読み上げ音声の出力(HDMI経由) |
使ったもの (ソフトウェア)
以下のものを使用しました。
| ソフト | 役割 |
|---|---|
| Raspberry Pi OS (64-bit) | OS |
| whisper.cpp(base モデル) | 音声を文字に変換 |
| Ollama + gemma3:1b | 返事を生成するローカルLLM |
| Open JTalk + MMDAgent「メイ」の声 | 文章を音声で読み上げ |
| Python 3(標準ライブラリのみ) | 全体をつなぐスクリプト |
仕組み
マイクは常に音を聞いていて、「ヘイ、ラズ」で始まる言葉だけを LLM に渡します。返事は語尾を整えてから読み上げ、会話は記憶として保存します。
工夫した点
1. ボタンなしで話しかけられるようにした
マイクの音量を常に見て「声が始まった・終わった」を自動で判定し、聞き取った文の先頭に「ヘイ、ラズ」があるときだけ反応するようにしました。起動時に周りの雑音を測ってしきい値を決めるので、部屋の環境が変わっても調整しやすくしています。
2. 語尾「だっぴ」でキャラクターをつけた
システムプロンプトで語尾を指定していますが、小さいモデルはよく指示を忘れます。そこで、返ってきた文の語尾が「だっぴ」になっていなければ、プログラム側で「です」→「だっぴ」のように直す保険を入れました。
3. 自分の体調として状態を答える
「調子」「温度」などの言葉が入っていたら、CPU温度・メモリ・ファンの回転数・電圧不足の有無を実際に読み取り、LLMに渡しています。「CPUが熱い → 少し熱っぽいだっぴ」のように、ラズパイ自身の体調として話させることで、ラズパイで動かしている意味が出るようにしました。
4. 長く使っても重くならない記憶
直近の会話はそのまま保存し、古くなった分はLLMに要約させて「覚えていること」として残します。要約は返事を読み上げた後に行うので、会話の待ち時間は増えません。
5. 声を可愛くした
Open JTalk の標準の男性の声から、MMDAgent の「メイ」の明るい声に変え、少し高く・速くしました。声が変わるだけで、話しかけたくなる気持ちがかなり変わりました。
ソースコードの一部
スクリプトは Python の標準ライブラリだけで書いていて、外部のプログラムは subprocess で呼び出しています。
声の始まりと終わりを判定する
30ミリ秒ごとに音の大きさ(RMS)を計算し、しきい値を3回続けて超えたら話し始め、0.8秒静かになったら話し終わりと判断します。
while True:
frame = proc.stdout.read(FRAME_BYTES)
loud = rms(frame) > self.threshold
if not started:
preroll.append(frame)
loud_run = loud_run + 1 if loud else 0
if loud_run >= 3:
started = True
voiced = list(preroll)
else:
voiced.append(frame)
silent = 0 if loud else silent + 1
if silent >= end_silence or len(voiced) >= max_frames:
break
語尾を「だっぴ」に直す
ENDINGS = (("でしょう", "だっぴ"), ("ですね", "だっぴね"), ("ですよ", "だっぴよ"),
("です", "だっぴ"), ("だよ", "だっぴよ"), ("だね", "だっぴね"), ("だ", "だっぴ"))
if body and not body.endswith("っぴ"):
for old, new in ENDINGS:
if body.endswith(old):
body = body[: -len(old)] + new
break
else:
body += "だっぴ"
体調の情報をLLMに渡す
get_status() では /sys/class/thermal/thermal_zone0/temp(CPU温度)や /proc/meminfo(メモリ)、vcgencmd get_throttled(電圧不足や速度低下の有無)を読んでいます。
status = get_status() if any(w in question for w in STATUS_WORDS) else None
if status:
content = f"【ラズパイの状態】\n{status}\n\n【質問】\n{question}"
振り返り
今回は Raspberry Pi 5 の上で、音声認識(whisper.cpp)・ローカルLLM(Ollama + gemma3)・音声合成(Open JTalk)を組み合わせてみました。その結果、クラウドのAPIを一切使わずに、「ヘイ、ラズ」と呼ぶだけで返事をしてくれて、自分の体調を話したり、前に話した内容を覚えていたりする話し相手を、手のひらサイズのラズパイだけで作ることができました。
一番難しいと感じたのは、「いつでも話しかけたくなる」ような愛着を持たせる工夫です。話し相手である以上、まずは質問にきちんと答えられていることが前提です。そのうえで、返事が返ってくるまでのテンポや、喋り方・声の雰囲気によって、話しかけたくなるかどうかが大きく変わると感じました。語尾や声を変えるだけでも印象はかなり変わったので、このあたりはまだまだ工夫の余地がありそうです。