0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【お手軽】ラズパイ4でオフライン英語音声チャットを作ってみた【Ollama+Vosk+eSpeakNG】

0
Last updated at Posted at 2026-05-24

はじめに

手元に放置されていた Raspberry Pi を使って何か作りたいな、と思って調べていたところ、完全オフラインで音声チャットができそうだったので試してみました。

本記事では、Raspberry Pi 4 上で 音声認識 → 生成 AI → 音声合成 を組み合わせ、英語で話しかけると英語で答えてくれる音声チャットアプリの構築手順をまとめます。

完成イメージ

あなた(英語で発話)
    ↓  Vosk(音声認識)
テキスト
    ↓  Ollama + Gemma2:2b(生成 AI)
AI の返答テキスト
    ↓  eSpeak NG(音声合成)
スピーカーから再生

クラウド API は使わず、ラズパイ単体で完結します。

使用技術

役割 ソフトウェア
音声認識(STT) Vosk
生成 AI(LLM) Ollama + gemma2:2b
音声合成(TTS) eSpeak NG
アプリ本体 Python 3(自作スクリプト ai_voice_chat_en.py

前提条件

  • Raspberry Pi 4 Model B(4GB 以上推奨)
  • Raspberry Pi OS(64bit 推奨)
  • PC から SSH 接続できること
  • Raspberry Pi の基本操作(ターミナル、apt、pip)に慣れていること

用意するもの

  • Raspberry Pi 4
  • microSD カード(32GB 以上推奨)
  • 電源アダプタ
  • USB マイク(またはマイク内蔵 USB Web カメラ)
  • スピーカー、または 3.5mm ステレオジャックのイヤホン/スピーカー
  • PC(SSH 接続・ファイル転送用)
  • AIと英語で会話する勇気

重要: Raspberry Pi 4 の 3.5mm ジャックは音声出力専用です。マイク入力には USB マイクが必要です。内蔵音声だけでは音声認識は動きません。

作業手順

1. SSH で Raspberry Pi に接続

SSH の有効化(raspi-configssh ファイルの作成など)は省略します。接続できている前提で進めます。

ssh ユーザー名@Raspberry_PiのIPアドレス

2. 作業ディレクトリを作成

以降の作業はこのディレクトリ内で行います。

mkdir -p ~/ai_voice_chat
cd ~/ai_voice_chat

3. スピーカーのテスト(3.5mm ジャックから出力)

まず音声出力ができるか確認します。

# オーディオ出力の設定(必要に応じて)
sudo raspi-config
# System Options → Audio → 3.5mm jack などを選択

# 左右チャンネルからテスト音を再生
speaker-test -c2

Ctrl + C で停止します。

注意: テスト音はかなり大きいです。ヘッドフォンを使っている場合は音量に注意してください。

(任意)MP3 再生テスト

eSpeak NG のテスト前に、別途 MP3 が再生できるか確認したい場合:

sudo apt update
sudo apt install -y mpg321
wget http://PCのIPアドレス:8080/hogehoge.mp3
mpg321 ./hogehoge.mp3 --gain 20

MP3 ファイルは PC 側で簡易 HTTP サーバーを立てて転送する方法が手軽です(後述の Tips を参照)。

4. 音声合成(eSpeak NG)の導入・テスト

sudo apt update
sudo apt install -y espeak-ng espeak-ng-data
espeak-ng "Hello, this is a test." -a 10

-a 10 は音量(0〜200)です。小さすぎる/大きすぎる場合は数値を調整してください。

5. USB マイクの認識確認・録音テスト

# USB デバイスが認識されているか確認
dmesg | tail

# 録音デバイス一覧
arecord -l

arecord -l の出力例:

card 1: WEBCAM [GENERAL WEBCAM], device 0: USB Audio [USB Audio]
  Subdevices: 1/1
  Subdevice #0: subdevice #0

この例では card 1, device 0 なので、5 秒間録音して再生します。

arecord -D plughw:1,0 -d 5 -f cd test.wav
aplay test.wav

つまずきポイント: plughw:1,01card 番号です。環境によって 0 になることもあるので、必ず arecord -l の結果に合わせて変更してください。録音に失敗する場合は -f S16_LE -r 16000 を試してください。

arecord -D plughw:1,0 -d 5 -f S16_LE -r 16000 test.wav
aplay test.wav

6. Ollama の導入と Gemma2:2b モデルのインストール

curl -fsSL https://ollama.com/install.sh | sh
ollama run gemma2:2b

初回実行時にモデルがダウンロードされます(数 GB)。プロンプトが表示されたら動作確認できたので Ctrl + D または /bye で終了します。

補足: gemma2:2b は多言語対応モデルですが、本記事では 英語の音声認識・音声合成に限定して試しています。

つまずきポイント: 以降の Python スクリプト実行時も Ollama サービスが動いている必要があります。うまくいかない場合は ollama serve が起動しているか、ollama list でモデルがあるか確認してください。

7. Ollama Python ライブラリの導入

Raspberry Pi OS(Bookworm 以降)では、pip の制限回避のため --break-system-packages が必要になることがあります。

pip3 install ollama --break-system-packages

8. Vosk と PyAudio の導入

sudo apt install -y portaudio19-dev python3-dev python3-pyaudio
pip3 install vosk pyaudio --break-system-packages

python3-pyaudio を apt で入れてから pip 版を入れると、ビルド失敗を避けやすいです。

9. Vosk モデルとスクリプトの配置

作業ディレクトリ(~/ai_voice_chat)に、次のファイル・フォルダを置きます。

ai_voice_chat/
├── ai_voice_chat_en.py
└── vosk-model-small-en-us-0.15/
    ├── am/
    ├── graph/
    └── ...

Vosk 英語モデルのダウンロード:

cd ~/ai_voice_chat
wget https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip
unzip vosk-model-small-en-us-0.15.zip
rm vosk-model-small-en-us-0.15.zip

スクリプト ai_voice_chat_en.py は PC から転送してください。

ai_voice_chat_en.py スクリプト本体
ai_voice_chat_en.py
import argparse
import contextlib
import json
import os
import re
import subprocess
import sys
from pathlib import Path

import ollama

# Ollama model name (Optimized for English conversation)
MODEL_NAME = "gemma2:2b"  # "tinyllama:latest"

SCRIPT_DIR = Path(__file__).resolve().parent
VOSK_MODEL_PATH = SCRIPT_DIR / "vosk-model-small-en-us-0.15"
SAMPLE_RATE = 16000

_EMOJI_PATTERN = re.compile(
    "["
    "\U0001F600-\U0001F64F"
    "\U0001F300-\U0001F5FF"
    "\U0001F680-\U0001F6FF"
    "\U0001F700-\U0001F77F"
    "\U0001F780-\U0001F7FF"
    "\U0001F800-\U0001F8FF"
    "\U0001F900-\U0001F9FF"
    "\U0001FA00-\U0001FA6F"
    "\U0001FA70-\U0001FAFF"
    "\U00002600-\U000026FF"
    "\U00002700-\U000027BF"
    "\U0001F1E0-\U0001F1FF"
    "\U0001F3FB-\U0001F3FF"
    "\U0000FE00-\U0000FE0F"
    "\U0000200D"
    "]+",
    flags=re.UNICODE,
)


def strip_emojis(text: str) -> str:
    """Remove emoji characters so espeak-ng does not try to pronounce them."""
    cleaned = _EMOJI_PATTERN.sub("", text)
    return re.sub(r"\s+", " ", cleaned).strip()


@contextlib.contextmanager
def suppress_stderr():
    """Redirect C library stderr noise (ALSA/JACK) to devnull."""
    stderr_fd = sys.stderr.fileno()
    saved_fd = os.dup(stderr_fd)
    try:
        with open(os.devnull, "w") as devnull:
            os.dup2(devnull.fileno(), stderr_fd)
            yield
    finally:
        os.dup2(saved_fd, stderr_fd)
        os.close(saved_fd)


def generate_ai_response(prompt: str) -> str:
    """Generate a response from Ollama based on the user's prompt."""
    print("AI is thinking...")
    try:
        response = ollama.chat(
            model=MODEL_NAME,
            messages=[
                {
                    "role": "system",
                    "content": "You are a helpful and concise voice assistant. "
                    "Keep your answers short (2-3 sentences max) and easy to read aloud. "
                    "Avoid using complex symbols, bullet points, markdown formatting, or emojis.",
                },
                {"role": "user", "content": prompt},
            ],
        )
        return response["message"]["content"]
    except Exception as e:
        print(f"Ollama Error: {e}")
        return "Sorry, I encountered an error and could not generate a response."


def speak_text(text: str):
    """Speak text aloud using espeak-ng."""
    speech_text = strip_emojis(text)
    if not speech_text:
        return

    print("Speaking...")
    # -v en-us: US English voice
    # -s 160  : Speaking speed (words per minute)
    try:
        with suppress_stderr():
            subprocess.run(
                ["espeak-ng", "-a", "10", "-v", "en-us", "-s", "160", speech_text],
                check=True,
            )
    except subprocess.CalledProcessError as e:
        print(f"Audio Command Error: {e}")


def load_vosk_model():
    """Load the local Vosk speech recognition model."""
    if not VOSK_MODEL_PATH.is_dir():
        print(f"Vosk model not found: {VOSK_MODEL_PATH}")
        print("Download the English model and place it in the project directory.")
        sys.exit(1)

    try:
        from vosk import Model, SetLogLevel
    except ImportError:
        print("Vosk is not installed. Run: pip install vosk")
        sys.exit(1)

    SetLogLevel(-1)
    return Model(str(VOSK_MODEL_PATH))


def list_input_devices():
    """Print PyAudio input devices and their index numbers."""
    try:
        import pyaudio
    except ImportError:
        print("PyAudio is not installed. Run: pip install pyaudio")
        sys.exit(1)

    with suppress_stderr():
        audio = pyaudio.PyAudio()
    try:
        print("Available input devices:")
        found = False
        for i in range(audio.get_device_count()):
            info = audio.get_device_info_by_index(i)
            if info.get("maxInputChannels", 0) > 0:
                found = True
                print(
                    f"  [{i}] {info['name']} "
                    f"(channels={int(info['maxInputChannels'])}, "
                    f"rate={int(info['defaultSampleRate'])})"
                )
        if not found:
            print("  (none found)")
    finally:
        with suppress_stderr():
            audio.terminate()


def resolve_input_device_index(audio, device_index=None) -> int:
    """Pick an input device, preferring the first available microphone."""
    if device_index is not None:
        info = audio.get_device_info_by_index(device_index)
        if info.get("maxInputChannels", 0) <= 0:
            print(f"Device [{device_index}] does not support input.")
            sys.exit(1)
        return device_index

    try:
        default_info = audio.get_default_input_device_info()
        return int(default_info["index"])
    except OSError:
        pass

    for i in range(audio.get_device_count()):
        info = audio.get_device_info_by_index(i)
        if info.get("maxInputChannels", 0) > 0:
            print(f"Using input device [{i}]: {info['name']}")
            return i

    print("No microphone input device found.")
    print("Connect a USB microphone/webcam and run with --list-devices.")
    sys.exit(1)


class VoiceInputSession:
    """Reuse one PyAudio instance to avoid repeated ALSA/JACK probe noise."""

    def __init__(self, model, input_device_index=None):
        import pyaudio

        self._pyaudio = pyaudio
        with suppress_stderr():
            self.audio = pyaudio.PyAudio()
            self.device_index = resolve_input_device_index(self.audio, input_device_index)
            self.device_name = self.audio.get_device_info_by_index(self.device_index)["name"]
        self.model = model

    def listen(self) -> str:
        from vosk import KaldiRecognizer

        recognizer = KaldiRecognizer(self.model, SAMPLE_RATE)
        with suppress_stderr():
            stream = self.audio.open(
                format=self._pyaudio.paInt16,
                channels=1,
                rate=SAMPLE_RATE,
                input=True,
                input_device_index=self.device_index,
                frames_per_buffer=8192,
            )

        print("Listening... (speak, then pause briefly when done)")

        try:
            while True:
                data = stream.read(4096, exception_on_overflow=False)
                if recognizer.AcceptWaveform(data):
                    result = json.loads(recognizer.Result())
                    return result.get("text", "").strip()

                partial = json.loads(recognizer.PartialResult()).get("partial", "")
                if partial:
                    print(f"\rYou: {partial}    ", end="", flush=True)
        finally:
            stream.stop_stream()
            stream.close()

    def close(self):
        with suppress_stderr():
            self.audio.terminate()


def get_text_input() -> str:
    return input("You: ").strip()


def parse_args():
    parser = argparse.ArgumentParser(description="Ollama voice chat with text or Vosk input.")
    parser.add_argument(
        "--voice",
        action="store_true",
        help="Use microphone input with Vosk speech recognition.",
    )
    parser.add_argument(
        "--input-device",
        type=int,
        default=None,
        help="PyAudio input device index (see --list-devices).",
    )
    parser.add_argument(
        "--list-devices",
        action="store_true",
        help="List available microphone devices and exit.",
    )
    return parser.parse_args()


def run_chat(use_voice: bool, input_device_index=None):
    vosk_model = load_vosk_model() if use_voice else None
    voice_session = None
    input_mode = "Voice (Vosk)" if use_voice else "Text"

    print("====================================")
    print("  Ollama + espeak-ng Voice Chat (EN)")
    print("====================================")
    print(f"Input mode: {input_mode}")
    print("Say or type 'exit' or 'quit' to stop the program.\n")

    if use_voice:
        voice_session = VoiceInputSession(vosk_model, input_device_index)
        print(f"Microphone: [{voice_session.device_index}] {voice_session.device_name}\n")

    try:
        while True:
            try:
                if use_voice:
                    user_input = voice_session.listen()
                    print(f"\rYou: {user_input}" if user_input else "\rYou: (no speech detected)")
                else:
                    user_input = get_text_input()

                if not user_input:
                    continue

                if user_input.lower() in ["exit", "quit"]:
                    print("Goodbye!")
                    break

                ai_response = generate_ai_response(user_input)
                print(f"\nAI: {ai_response}\n")

                speak_text(ai_response)
                print("-" * 40)

            except KeyboardInterrupt:
                print("\nProgram interrupted.")
                sys.exit(0)
    finally:
        if voice_session is not None:
            voice_session.close()


def main():
    args = parse_args()
    if args.list_devices:
        list_input_devices()
        return
    run_chat(use_voice=args.voice, input_device_index=args.input_device)


if __name__ == "__main__":
    main()

# PC 側で簡易サーバーを立てている場合(Pi 上で実行)
wget http://PCのIPアドレス:8080/ai_voice_chat_en.py

つまずきポイント: Vosk モデルは zip を解凍したフォルダごと ai_voice_chat_en.py と同じディレクトリに置いてください。フォルダ名は vosk-model-small-en-us-0.15 である必要があります(スクリプト内でこのパスを参照しています)。

10. 音声チャットスクリプトの実行

音声入力モード(本記事のメイン)

cd ~/ai_voice_chat
python3 ai_voice_chat_en.py --voice

Listening... と表示されたら 英語で話しかけ、少し間を空けると認識されます。終了するときは exit または quit と話しかけるか、Ctrl + C です。

テキスト入力モード(動作確認用)

python3 ai_voice_chat_en.py

キーボード入力で AI と会話できます。音声まわりを切り分けて試したいときに便利です。

その他のオプション

# 利用可能なマイクデバイス一覧
python3 ai_voice_chat_en.py --list-devices

# マイクを明示指定(一覧で確認した番号)
python3 ai_voice_chat_en.py --voice --input-device 1

トラブルシューティング

ALSA lib や JACK 関連のメッセージが大量に出る

PyAudio 初期化時に ALSA lib ...jack server is not running が stderr に出ることがあります。Pi ではよくある現象で、動作に問題がなければ無視して大丈夫です。

最新版の ai_voice_chat_en.py では、これらのメッセージを抑制する処理を入れています。

Unknown PCM input と出てマイクが使えない

Pi 4 の内蔵音声には入力がありません。USB マイクを接続し、arecord -l でデバイスが見えることを確認してください。

音声は認識されるが英語の聞き取り精度が低い

  • マイクを口元に近づける
  • 静かな環境で話す
  • 発話後、1 秒ほど間を空けてから次の操作をする
  • より大きな Vosk モデル(vosk-model-en-us-0.22 など)に差し替える

AI の返答がおかしい・嘘をつく

gemma2:2b は軽量モデルのため、ハルシネーション(もっともらしい誤情報)が出やすいです。本記事の用途(オフラインで動くことの確認)を前提に、精度より手軽さを優先しています。

絵文字を読み上げてしまう

AI が返答に絵文字を含めることがあります。スクリプト側で TTS 前に絵文字を除去する処理を入れています。

その他 Tips

PC と Raspberry Pi 間でファイルをやり取りする

PC 側で簡易 HTTP サーバーを立てる

ファイルがあるディレクトリで:

python3 -m http.server 8080

Raspberry Pi からファイルを取得

wget http://PCのIPアドレス:8080/ファイル名

Windows(PowerShell)から Raspberry Pi のファイルを取得

curl -O http://Raspberry_PiIPアドレス:8080/ファイル名

SCP で転送(参考)

# PC → Pi
scp ai_voice_chat_en.py ユーザー名@Raspberry_PiのIPアドレス:~/ai_voice_chat/

音声デバイスの権限

録音できない場合、ログインユーザーが audio グループに入っているか確認します。

groups
# audio が無ければ
sudo usermod -aG audio $USER
# 再ログインが必要

注意点

  • 本記事の構成は 英語限定 です(Vosk 英語モデル + eSpeak NG 英語 voice)
  • 初回のモデルダウンロード(Ollama、Vosk)には時間とディスク容量が必要です
  • LLM の応答品質は PC 上の大規模モデルには及びません
  • ai_voice_chat_en.py の実装や本記事の執筆にはCursorエディタを使用しました

さいごに

比較的非力な Raspberry Pi 4 単体で、それほど手間をかけずにオフライン AI 音声チャットが動くのは、なかなか面白い体験だと思います。

Raspberry Pi は GPIO や I2C など外部入出力も豊富なので、ボタンや LED、センサーと組み合わせれば、hands-free アシスタントや簡易ロボット制御などにも応用できそうです。

同じような試みをしている方の参考になれば幸いです。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?