クラウド音声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("モデルの読み込みが完了しました")
利用可能なモデルサイズ:tiny、base、small、medium、large。会話用途ではsmallかmediumが実用的なバランスがとれている——精度が特に重要でない限り、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モデル監視を組み合わせることで、さらに安定した運用が実現できる。

