WhisperとOllamaでローカル音声アシスタントを構築する:完全オフラインの音声認識とLLM応答

AI tutorial - IT technology blog
AI tutorial - IT technology blog

クラウド音声APIをやめた理由

半年前、私が行う音声クエリはすべてクラウドAPIを経由していた——文字起こしにはGoogle Speech-to-Text、応答にはOpenAIのAPI。最初はコストも許容範囲内だった。しかし、レイテンシーが体験を台無しにした。モデルが処理を始める前から、ネットワークのオーバーヘッドだけで毎回1.5〜2秒の往復遅延が発生していた。

さらにプライバシーの問題もあった。私は社内ドキュメントを扱うことが多く、ネットワーク外に出すべきでないシステムについて質問することもある。その音声をサードパーティのサービスに送信するのは気持ち悪かった——正直に言えば、実際に問題だった。

Whisper(OpenAIのオープンソース音声認識モデル)とOllama(ローカルLLMランナー)を組み合わせることで、両方の問題が一度に解決した。このスタックを本番環境で6ヶ月間運用してきた結果:クォータ制限ゼロ、API料金ゼロ、そしてミドルレンジのハードウェアでもクラウドソリューションに匹敵する応答速度を実現している。

具体的なセットアップ方法を紹介する。

実際に必要なもの

まずハードウェア要件を確認しておこう。WhisperのmediumモデルはCPUでも動作するが、それより大きいモデルはGPUがあると大幅に速くなる。Ollamaでは、MistralやLLaMA 3のような7Bパラメータモデルに最低8GBのRAMが必要——16GBあれば余裕を持って動かせる。

私の環境:Ubuntu 22.04、RAM 32GB、NVIDIA RTX 3060(VRAM 12GB)。Apple SiliconのmacOSを使っている場合、OllamaのMetalサポートが優秀で、M2やM3では本当に印象的なパフォーマンスを発揮する。

必要なパッケージ:

  • Python 3.10+
  • ffmpeg(音声処理)
  • portaudio(マイク入力)
  • Ollama

インストール

ステップ1:Ollamaのインストール

Ollamaはワンライナーでインストールでき、systemdサービスの設定も含めてすべて自動で行われる:

curl -fsSL https://ollama.com/install.sh | sh

使用するモデルをプルする。コンシューマー向けハードウェアでは、速度と性能のバランスが良いmistralがおすすめだ。VRAM 8GB未満のマシンなら、phi3の方が明らかに軽い。複数のモデルやプロバイダーを一元管理したい場合は、LiteLLMでローカルAIゲートウェイを構築する方法も参考になる:

ollama pull mistral
# または軽量なモデルを使う場合:
ollama pull phi3

次の手順に進む前に、正常に動作しているか確認しておこう:

ollama list
# ダウンロード済みのモデルが表示されるはず

curl http://localhost:11434/api/generate -d '{
  "model": "mistral",
  "prompt": "Hello, are you working?",
  "stream": false
}'

ステップ2:Python環境のセットアップ

python3 -m venv voice-assistant-env
source voice-assistant-env/bin/activate

# 先にシステム依存パッケージをインストール
sudo apt install -y ffmpeg portaudio19-dev python3-dev

# Pythonパッケージをインストール
pip install openai-whisper pyaudio requests numpy

macOSの場合:

brew install ffmpeg portaudio
pip install openai-whisper pyaudio requests numpy

ステップ3:Whisperモデルのダウンロード

Whisperは初回使用時にモデルを自動でダウンロードする。事前にダウンロードしておけば、最初の実行時の待ち時間を避けられる:

import whisper
# モデルをダウンロードしてキャッシュする('medium'は約1.5GB)
model = whisper.load_model("medium")
print("モデルの読み込みが完了しました")

利用可能なモデルサイズ:tinybasesmallmediumlarge。会話用途ではsmallmediumが実用的なバランスがとれている——精度が特に重要でない限り、largeはオーバースペックだ。

設定

音声アシスタントのメインスクリプト

以下は私が本番環境で実際に動かしているスクリプトの全文だ。音声を録音し、Whisperで文字起こしし、テキストをOllamaに送信して応答を表示する:

import whisper
import pyaudio
import wave
import requests
import json
import os
import tempfile
import numpy as np

# ── 設定 ──────────────────────────────────────────────
WHISPER_MODEL = "medium"        # 応答を速くしたい場合は "small" に変更
OLLAMA_MODEL = "mistral"        # ollamaでプルしたモデル名と一致させること
OLLAMA_URL = "http://localhost:11434/api/generate"

# 音声録音の設定
SAMPLE_RATE = 16000
CHUNK_SIZE = 1024
CHANNELS = 1
FORMAT = pyaudio.paInt16
RECORD_SECONDS = 5              # 典型的な質問の長さに合わせて調整
SILENCE_THRESHOLD = 500         # マイクの感度に合わせて調整

# ── 起動時にWhisperモデルを一度だけ読み込む ─────────────────────────
print(f"Whisperモデルを読み込み中: {WHISPER_MODEL}")
whisper_model = whisper.load_model(WHISPER_MODEL)
print("準備完了。Enterキーを押して録音を開始してください。")


def record_audio() -> str:
    """マイクから音声を録音して一時ファイルに保存する。"""
    audio = pyaudio.PyAudio()
    stream = audio.open(
        format=FORMAT,
        channels=CHANNELS,
        rate=SAMPLE_RATE,
        input=True,
        frames_per_buffer=CHUNK_SIZE
    )

    print("録音中...(今話してください)")
    frames = []

    for _ in range(0, int(SAMPLE_RATE / CHUNK_SIZE * RECORD_SECONDS)):
        data = stream.read(CHUNK_SIZE)
        frames.append(data)

    print("録音完了。")
    stream.stop_stream()
    stream.close()
    audio.terminate()

    # 一時WAVファイルに保存
    tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
    with wave.open(tmp.name, 'wb') as wf:
        wf.setnchannels(CHANNELS)
        wf.setsampwidth(audio.get_sample_size(FORMAT))
        wf.setframerate(SAMPLE_RATE)
        wf.writeframes(b''.join(frames))

    return tmp.name


def transcribe(audio_path: str) -> str:
    """Whisperを使って音声ファイルを文字起こしする。"""
    result = whisper_model.transcribe(audio_path, language="en")
    os.unlink(audio_path)  # 一時ファイルを削除
    return result["text"].strip()


def ask_ollama(prompt: str) -> str:
    """ローカルのOllamaインスタンスにプロンプトを送信して応答を返す。"""
    payload = {
        "model": OLLAMA_MODEL,
        "prompt": prompt,
        "stream": False,
        "options": {
            "temperature": 0.7,
            "num_predict": 256  # 音声向けに応答を簡潔に保つ
        }
    }
    response = requests.post(OLLAMA_URL, json=payload, timeout=60)
    response.raise_for_status()
    return response.json()["response"].strip()


def main():
    while True:
        input("\nEnterキーを押して話してください(終了はCtrl+C)...")
        audio_path = record_audio()

        print("文字起こし中...")
        text = transcribe(audio_path)

        if not text:
            print("音声が検出されませんでした。もう一度試してください。")
            continue

        print(f"あなたの発言: {text}")
        print("考え中...")

        response = ask_ollama(text)
        print(f"\nアシスタント: {response}\n")


if __name__ == "__main__":
    main()

ハードウェアに合わせたチューニング

実際に効果のある設定が3つある:

  • Whisperの言語設定:言語が分かっている場合は必ず指定すること(language="en")。自動検出はレイテンシーが増えるうえ、たまに間違った言語を選んでしまう——BGMが流れている状態で英語を話したとき、フランス語として文字起こしされたことがある。
  • num_predict:Ollamaの出力を256トークンに制限することで、音声応答を短く聞きやすい長さに保てる。2000トークンのエッセイは画面で読むのも辛いが、声で聞くのはさらに耐えられない。
  • 録音秒数:5秒で大抵の短いコマンドには十分だ。会話のやり取りには8〜10秒に増やすといい。

一貫した動作のためのシステムプロンプトを追加する

システムペルソナを設定することで、Ollamaが短く音声に適した回答をするよう誘導できる。設定しないと、応答が冗長になったり、ターミナル出力で見苦しいマークダウンが含まれたりしがちだ:

SYSTEM_PROMPT = (
    "あなたは簡潔な音声アシスタントです。すべての応答を3文以内に収めてください。"
    "マークダウン書式は使わないでください。声に出して答えるように話してください。"
)

def ask_ollama(user_input: str) -> str:
    full_prompt = f"{SYSTEM_PROMPT}\n\nUser: {user_input}\nAssistant:"
    payload = {
        "model": OLLAMA_MODEL,
        "prompt": full_prompt,
        "stream": False,
        "options": {"temperature": 0.7, "num_predict": 256}
    }
    response = requests.post(OLLAMA_URL, json=payload, timeout=60)
    return response.json()["response"].strip()

検証とモニタリング

各コンポーネントを個別にテストする

フルパイプラインを実行する前に、各パーツを単独でテストしよう。最初にセットアップしたとき、これをやっておいたおかげで数時間のデバッグを節約できた——OllamaのログからWhisperの問題を追跡するのは本当に面倒だ。

Whisper単体のテスト:

# 5秒のクリップを録音する
python3 -c "
import sounddevice as sd
import scipy.io.wavfile as wav
import numpy as np

fs = 16000
duration = 5
print('録音中...')
audio = sd.rec(int(duration * fs), samplerate=fs, channels=1, dtype='int16')
sd.wait()
wav.write('test.wav', fs, audio)
print('test.wavに保存しました')
"

# 文字起こしする
python3 -c "
import whisper
m = whisper.load_model('medium')
result = m.transcribe('test.wav', language='en')
print(result['text'])
"

Ollamaの単体テスト:

curl http://localhost:11434/api/generate \
  -d '{"model": "mistral", "prompt": "What is 2+2?", "stream": false}' \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['response'])"

レイテンシーの計測

RTX 3060で6ヶ月間運用した結果、安定した基準値が得られた:

  • Whisper medium(GPU使用):5秒クリップで約0.8〜1.2秒
  • Ollama(Mistral 7B、GPU使用):256トークンの応答で約1.5〜3秒
  • 合計往復時間:およそ2.5〜4秒

ネットワークレイテンシーを考慮すると、クラウドAPIと十分に競争できる数値だ。しかも完全にローカルで動いている。

時間の経過とともに追跡できるよう、基本的なタイミング計測を組み込んでおこう:

import time

start = time.time()
text = transcribe(audio_path)
print(f"文字起こし: {time.time() - start:.2f}秒")

start = time.time()
response = ask_ollama(text)
print(f"LLM応答: {time.time() - start:.2f}秒")

バックグラウンドサービスとして実行する

常時起動のセットアップには、Pythonスクリプトをsystemdサービスとしてラップするといい。Ollamaはデフォルトですでにサービスとして動いているので、アシスタントをそれに並べて追加するだけだ:

sudo nano /etc/systemd/system/voice-assistant.service
[Unit]
Description=ローカル音声アシスタント
After=ollama.service

[Service]
Type=simple
User=youruser
WorkingDirectory=/home/youruser/voice-assistant
ExecStart=/home/youruser/voice-assistant/voice-assistant-env/bin/python assistant.py
Restart=on-failure

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now voice-assistant

注意すべき点

本番環境で繰り返し発生する問題が3つある:

  • OllamaのVRAM不足:Ollamaと並行して他のGPUワークロードを動かすと、モデルがGPUから追い出されることがある。nvidia-smiで確認しよう——モデルがシステムRAMにスピルオーバーするのではなく、GPUメモリに読み込まれていることを確認したい。
  • 無音時のWhisperの誤動作:録音中に誰も話していない場合、Whisperは空文字列ではなくゴミのようなテキストを返す。Ollamaに何かを送信する前にシンプルなRMSエネルギーチェックを追加しておこう——音声が無音なら処理をスキップする。
  • 長時間セッションでのメモリ増加:念のため毎週サービスを再起動している。2ヶ月経った頃、コールドスタートからRAM使用量が約800MB増えているのに気づいた。毎週の再起動でクリーンな状態を保てる。

Whisper mediumとMistral 7Bを合わせたフルスタックは、両方が読み込まれた状態でVRAMを約6GB、システムRAMを約4GB使用する。6ヶ月の本番運用で、計画外の再起動はわずか2回——どちらも停電が原因だった。これは十分満足できる信頼性だと思っている。本番環境でのAIモデル監視を組み合わせることで、さらに安定した運用が実現できる。

Share: