2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LLM8850でVOICEVOXとおしゃべり

2
Last updated at Posted at 2025-11-09

はじめに

この記事は、AIアクセラレータLLM8850を搭載したRaspberry Pi 5で、VOICEVOX音声のAIと日本語でおしゃべりすることを目的としています。

※ Geminiによるイメージ図
Gemini_Generated_Image_hsfh8qhsfh8qhsfh.png

ハードウェア構成

  • Raspberry Pi 5(8GB)
    2GBだと厳しいですが4GBならいけるかもしれません。(未確認 後述

  • Raspberry Pi 5用アクティブクーラー
    動作中は頻繁に70度に達して冷却、を繰り返していました。
    80度まで上がって処理が落ちない程度の冷却が必須です。

  • LLM8850
    MakerFareTokyo2025で購入しました。
    国内ではSWITCH SCIENCEさんの販売のみ? ※執筆中現在売り切れ中
    SWITCH SCIENCE LLM8850販売ページ

  • Raspberry Pi M.2 HAT+
    M.2変換はいろいろありますがLLM8850は電力や帯域幅の制約があり、公式の変換ボードを使うのがよいそうです。参考
    秋月電子 M.2 Hat+

  • USBスピーカー&マイク
    Raspberry Pi 5だとオーディオジャックがついてません。
    Bluetoothだとすっきりしますがいろいろ設定が必要そうなのでUSBにしました。

  • 電源
    LLM8850の公式ガイドにはDC 5V@3Aとありますが、Raspberry Pi 5でも同時にVOICEVOXをフル動作させると4Aを超えることがあり、電圧低下も見られました。
    5.1V@5AのACアダプタを使っておくのが無難と思われます。
    ワットチェッカーを使って計測していましたが、ワットチェッカー接続時のみ低電圧警告と強制シャットダウンが発生していました。電圧低下はこれのケーブルのせいかも

  • USB3.0接続SSD
    数GBのファイルの読み書きが頻繁に行われるので、マイクロSDではなくSSD起動にしておくことをお勧めします。
    デュアルM.2 HatでM.2 SSDと併用、はできなそうです。
    スクリーンショット 2025-11-09 12.10.15.png

LLM8850について

LLM8850はM5Stack社から今年10月?に発売された、M.2対応のAIアクセラレータです。
Raspberry Pi 5にM.2 HAT+経由で接続することができます。
Raspberry Pi 5で使えるAIアクセラレータとしてはAI HAT+がありましたが、画像処理に特化していました。
LLM8850は画像処理に加えて、LLMや音声処理もできるようになったもの、と思えばいいかと思います。

LLM8850の使い方

基本的なLLM8850の環境設定は公式チュートリアル他記事を参考にしてください。
本記事では、LLM8850を使った標準入出力方式でのLLM動作確認ができていることを前提にしています。

LLMモデルのAPI化

公式ページにいくつかモデルごとのサンプルがありますが、このページにAPI化の方法が載っています。
https://docs.m5stack.com/ja/guide/ai_accelerator/llm-8850/m5_llm_8850_qwen3_1.7b

サンプルページのモデルはQwen3-1.7Bですが、本記事では別のQwen3-4BをAPI化してみます。

1. Qwen3 4Bモデルを入手

公式ガイドにリンクはないですが、こちらにあるモデルなら動くのではないかと思います。
Qwen2.5 7Bモデルなんてのもありますが2.0 tokens/secはさすがに・・。

$ git clone https://huggingface.co/AXERA-TECH/Qwen3-4B
$ cd Qwen3-4B

2. python仮想環境の作成と依存パッケージのインストール

$ python -m venv env
$ source env/bin/activate
(env) $ pip install transformers jinja2 torch

3. API用調整

実行ファイルの権限追加、および実行スクリプトをRaspberry Pi 5用に調整します。

$ chmod +x main_api_axcl_aarch64 
$ cp run_qwen3_4b_int8_ctx_axcl_x86_api.sh run_qwen3_4b_int8_ctx_axcl_aarch64_api.sh
$ chmod +x run_qwen3_4b_int8_ctx_axcl_aarch64_api.sh
$ vi run_qwen3_4b_int8_ctx_axcl_aarch64_api.sh 
# 変更前のファイル内容 (一部抜粋)
-./main_api_axcl_x86 \
- --system_prompt "You are Qwen, a helpful assistant." \
- --devices=0,1
+./main_api_axcl_aarch64 \
+ --system_prompt "あなたはユーザーの質問に正確に答えるAIアシスタントです。" \
+ --devices=0

4. 実行

標準入出力方式のチュートリアルと同様に複数ターミナルで起動します。
ターミナル1 (標準入出力方式と同じ)

(env) $ python qwen3_tokenizer_uid.py 

ターミナル2 (apiとついてる方のファイルであることに注意)

$ ./run_qwen3_4b_int8_ctx_axcl_aarch64_api.sh

ターミナル3
リクエストすると返答の生成開始

$ curl -X POST "http://localhost:8000/api/generate" \
    -H "Content-Type: application/json" \
    -d '{
           "prompt": "あなたの名前は?",
           "temperature": 0.7,
           "top-k": 40
         }'

生成中の文章をポーリングして取得

curl "http://localhost:8000/api/generate_provider" 

実行例
スクリーンショット 2025-11-06 22.35.47.png

動かない時

  • run_qwen3_4b_int8_ctx_axcl_aarch64_api.sh 実行時に止まってしまう場合
[I][                            Init][ 263]: LLM init ok
Server running on port 8000...
[I][                             run][  80]: AXCLWorker exit with devid 0

OpenAIのAPIとは共存できないようなので止める必要があります。

$ sudo systemctl stop llm-openai-api

VOICEVOX

1. Dockerのインストール

$ curl -fsSL https://get.docker.com -o get-docker.sh
$ sudo sh get-docker.sh
$ sudo gpasswd -a $USER docker
$ newgrp docker
$ docker run --rm hello-world

2. VOICEVOXエンジンの取得と起動

デフォルトだとCPUコアの半分しか使わないので、Raspberry Pi 5の4コアすべて使う設定にします。

$ docker pull voicevox/voicevox_engine:cpu-ubuntu20.04-latest
$ docker run --rm -it -d -e VV_CPU_NUM_THREADS=4 -p ':50021:50021' voicevox/voicevox_engine:cpu-ubuntu20.04-latest

3. テスト実行

スピーカーからずんだもんの声が聞こえれば成功です

$ curl -s -X POST "localhost:50021/audio_query?speaker=1" --get --data-urlencode "text=これはテストなのだ" > query.json \
&& curl -s -H "Content-Type: application/json" -X POST -d @query.json "localhost:50021/synthesis?speaker=1" > audio.wav \
&& aplay audio.wav

メインプログラム

構成要素

機能 使用技術 役割 最適化ポイント
音声認識 (STT) Faster-Whisper マイク音声をテキスト化 CPU最適化 (compute_type="int8")
大規模言語モデル (LLM) LLM8850 API (localhost:8000) 質問応答、推論 ポーリングによるストリーミング
音声合成 (TTS) VOICEVOX (localhost:50021) テキストを高品質な音声へ 4コアフル活用 (VV_CPU_NUM_THREADS=4)
並列処理 Python threading & queue 合成と再生の非同期化 キューイングによる再生の逐次実行

処理の流れ

  1. 5秒間録音
  2. Faster-Whisperで音声認識
  3. 認識結果をユーザープロンプトとしてLLMにAPIリクエスト
  4. 生成結果をポーリングして細かく取得(ストリーミング処理)
  5. 生成テキストがチャンクで区切れたら不要なタグを除去し、VOICEVOXにリクエストして読み上げ音声を生成。
    WAVファイルを再生キューに追加(非同期)。
  6. 再生キュー管理
    最初の文のWAVファイルは、2文目の合成が完了するまで再生を遅延(途切れ防止の工夫)。
    バックグラウンドの再生ワーカーが、キューからWAVファイルを順番に取り出し、再生が完了するまで逐次実行(再生の重なり防止)。
  7. すべての音声読み上げが終わったら(再生キューが空になったら)、LLMキャッシュをリセット。
  8. 1に戻る

python環境構築

$ cd 
$ mkdir speakllm
$ cd speakllm
$ python -m venv env
$ source env/bin/activate
(env) $ pip install pyaudio numpy requests faster_whisper

実行

$ vi speakllm.py
実行スクリプト(クリックして展開) ※ほぼGemini製
speakllm.py
import pyaudio
import numpy as np
import wave
import os
import requests
import json
import subprocess
import time
import threading
import queue 
import re
from faster_whisper import WhisperModel

# ===============================================
# 1. 設定
# ===============================================
CHUNK = 1024
FORMAT = pyaudio.paInt16
CHANNELS = 1
RATE = 16000 
RECORD_SECONDS = 5          
WAVE_RECORD_FILENAME = "temp_recording.wav"
MODEL_SIZE = "small"        
LLM_API_BASE = "http://localhost:8000"
LLM_GENERATE_URL = f"{LLM_API_BASE}/api/generate"
LLM_POLLING_URL = f"{LLM_API_BASE}/api/generate_provider"
LLM_RESET_URL = f"{LLM_API_BASE}/api/reset" 
LLM_TEMP = 0.7
LLM_TOP_K = 40
# 繰り返し防止
LLM_REPETITION_PENALTY = 1.2 
# システムプロンプト ここでAIの性格付け等を記入
SYSTEM_PROMPT = "あなたは「ずんだもん」という名前の妖精です。枝豆や「ずんだ餅」が好物です。一人称は「僕」、語尾に「〜のだ」「〜なのだ」をつけてフレンドリーに話してください。性別不詳ですが男の子っぽい話し方をしてください。回答は簡潔にし、同じ内容や表現を繰り返さないでください。"
VOICEVOX_API_URL = "http://localhost:50021" 
# 話者ID ずんだもん(NORMAL)の場合3
SPEAKER_ID = 3
# 読み上げ速度
SPEED_SCALE = 0.9

playback_queue = queue.Queue() 
TEMP_FILE_PATH = "temp_files" 
SHOULD_STOP_WORKER = threading.Event()
CONVERSATION_HISTORY = [
    {"role": "system", "content": SYSTEM_PROMPT}
]
# ===============================================

# --- Whisper モデルのロード ---
print(f"Loading Whisper model: {MODEL_SIZE}...")
try:
    WHISPER_MODEL = WhisperModel(MODEL_SIZE, device="cpu", compute_type="int8")
    print("Whisper Model loaded successfully.")
except Exception as e:
    print(f"Error loading Whisper model: {e}")
    exit()

# ===============================================
## 2. ヘルパー関数群
# ===============================================

def transcribe_audio():
    p = pyaudio.PyAudio()
    stream = p.open(format=FORMAT, channels=CHANNELS, rate=RATE, input=True, frames_per_buffer=CHUNK)
    print(f"\n🎙️ Recording for {RECORD_SECONDS} seconds. Speak now...")
    frames = []
    for _ in range(0, int(RATE / CHUNK * RECORD_SECONDS)):
        data = stream.read(CHUNK, exception_on_overflow=False)
        frames.append(data)
    print("Recording finished. 📝 Transcribing...")
    stream.stop_stream()
    stream.close()
    p.terminate()
    with wave.open(WAVE_RECORD_FILENAME, 'wb') as wf:
        wf.setnchannels(CHANNELS)
        wf.setsampwidth(p.get_sample_size(FORMAT))
        wf.setframerate(RATE)
        wf.writeframes(b''.join(frames))
    recognized_text = ""
    try:
        segments, _ = WHISPER_MODEL.transcribe(WAVE_RECORD_FILENAME, beam_size=5, language="ja")
        for segment in segments:
            recognized_text += segment.text
    except Exception as e:
        print(f"Error during transcription: {e}")
    if os.path.exists(WAVE_RECORD_FILENAME):
        os.remove(WAVE_RECORD_FILENAME)
    return recognized_text.strip()

def voicevox_to_speech(text, output_filename):
    if not text.strip(): return False
    query_url = f"{VOICEVOX_API_URL}/audio_query"
    query_params = {"text": text, "speaker": SPEAKER_ID}
    try:
        query_response = requests.post(query_url, params=query_params)
        query_response.raise_for_status()
    except requests.exceptions.RequestException:
        print(f"VOICEVOX audio_query エラー。エンジン起動を確認してください。")
        return False
    query_data = query_response.json()
    query_data['speedScale'] = SPEED_SCALE
    synthesis_url = f"{VOICEVOX_API_URL}/synthesis"
    synthesis_params = {"speaker": SPEAKER_ID}
    try:
        synthesis_response = requests.post(
            synthesis_url, 
            params=synthesis_params, 
            json=query_data, 
            timeout=30
        )
        synthesis_response.raise_for_status()
    except requests.exceptions.RequestException as e:
        print(f"VOICEVOX synthesis エラー: {e}")
        return False
    with open(output_filename, 'wb') as f:
        f.write(synthesis_response.content)
    return True

def play_audio(filename):
    playback_queue.put(filename)

def playback_worker():
    print("  [Worker] Playback worker started.")
    while not SHOULD_STOP_WORKER.is_set():
        try:
            filename = playback_queue.get(timeout=0.1) 
            if os.path.exists(filename):
                try:
                    subprocess.run(["aplay", filename], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                except FileNotFoundError:
                    print("エラー: 'aplay' コマンドが見つかりません。")
                except subprocess.CalledProcessError:
                    pass
                finally:
                    os.remove(filename)
                    playback_queue.task_done() 
        except queue.Empty:
            continue
        except Exception as e:
            print(f"致命的な再生エラー: {e}")
            break
    while not playback_queue.empty():
        filename = playback_queue.get_nowait()
        if os.path.exists(filename):
            os.remove(filename)
    print("  [Worker] Playback worker stopped.")

def reset_llm_context():
    """LLM8850 APIにリセットコマンドを送信し、ローカルの履歴をリセットする"""
    global CONVERSATION_HISTORY
    
    print("\n⚠️ LLMサーバーのコンテキストをリセットしています...")
    
    reset_payload = {
        "system_prompt": SYSTEM_PROMPT
    }
    
    try:
        response = requests.post(LLM_RESET_URL, json=reset_payload, timeout=10)
        response.raise_for_status()
        
        CONVERSATION_HISTORY = [
            {"role": "system", "content": SYSTEM_PROMPT}
        ]
        print("✅ LLMサーバーのキャッシュとローカル履歴をリセットしました。")
        
    except requests.exceptions.RequestException as e:
        print(f"❌ LLMリセットAPIとの通信エラー: {e}。推論の継続は保証できません。")


# ===============================================
## 3. コアロジック: LLM8850 ポーリング処理
# ===============================================

def build_llm_prompt_from_history(current_prompt):
    """会話履歴をLLM8850 APIが処理できる単一のプロンプト文字列に整形する"""
    global CONVERSATION_HISTORY
    
    full_prompt = ""
    for message in CONVERSATION_HISTORY:
        role = message["role"]
        content = message["content"]
        
        if role == "user":
            full_prompt += f"ユーザー: {content}\n"
        elif role == "assistant":
            full_prompt += f"アシスタント: {content}\n"
    
    full_prompt += f"ユーザー: {current_prompt}\nアシスタント:"
    
    return full_prompt.strip()

def stream_and_speak(current_prompt):
    """
    LLM8850 APIにリクエストを開始し、ポーリングで応答を逐次取得し、再生キューに入れる
    """
    global TEMP_FILE_PATH, CONVERSATION_HISTORY
    
    if not os.path.exists(TEMP_FILE_PATH):
        os.makedirs(TEMP_FILE_PATH)
    
    llm_input_prompt = build_llm_prompt_from_history(current_prompt)
    
    print(f"\n🤖 Starting generation on LLM8850...")
    
    generate_payload = {
        "prompt": llm_input_prompt,
        "temperature": LLM_TEMP,
        "top-k": LLM_TOP_K,
        "repetition-penalty": LLM_REPETITION_PENALTY 
    }
    
    try:
        response = requests.post(LLM_GENERATE_URL, json=generate_payload, timeout=60)
        response.raise_for_status()
        start_result = response.json()
        
        if start_result.get("status") != "ok":
            print(f"エラー: LLM推論開始に失敗しました: {start_result}")
            return None
        
    except requests.exceptions.RequestException as e:
        print(f"エラー: LLM推論開始APIとの通信エラー: {e}")
        return None

    # 3. LLM応答のポーリング (API Step 2: /api/generate_provider)
    print("--- アシスタント応答 ---")
    full_response = ""
    current_chunk = ""
    punctuation = "。、?!\n"
    POLLING_INTERVAL = 0.1 
    
    tts_chunk_count = 0 

    while True:
        try:
            poll_response = requests.get(LLM_POLLING_URL, timeout=10)
            poll_response.raise_for_status()
            
            data = poll_response.json()
            is_done = data.get("done", False)
            text_chunk = data.get("response", "")
            
            if text_chunk:
                full_response += text_chunk
                current_chunk += text_chunk

            # 句読点、または完了信号でチャンクを処理
            if is_done or any(p in current_chunk for p in punctuation):
                
                tts_text = ""
                if is_done:
                    tts_text = current_chunk
                    current_chunk = ""
                else:
                    break_index = -1
                    if "" in current_chunk:
                        break_index = current_chunk.rfind("")
                    else:
                        for p in punctuation:
                             if p in current_chunk:
                                 break_index = max(break_index, current_chunk.rfind(p))

                    if break_index != -1:
                        tts_text = current_chunk[:break_index + 1]
                        current_chunk = current_chunk[break_index + 1:] 
                
                # TTS生成リクエスト (VOICEVOX) とキューイング
                if tts_text.strip():
                    filtered_text = re.sub(r'<[^>]+>.*?</[^>]+>', '', tts_text, flags=re.DOTALL)
                    filtered_text = re.sub(r'<[^>]+?>', '', filtered_text)
                    
                    if filtered_text.strip():
                        print(f"{filtered_text}")
                        
                        tts_chunk_count += 1
                        
                        temp_wav_file = os.path.join(TEMP_FILE_PATH, f"temp_tts_{int(time.time() * 1000)}.wav")
                        
                        if voicevox_to_speech(filtered_text, temp_wav_file):
                            
                            if tts_chunk_count > 1:
                                play_audio(temp_wav_file)
                            else:
                                playback_queue.put(temp_wav_file)
                            
                if is_done:
                    if tts_chunk_count > 0:
                        try:
                            first_chunk_file = playback_queue.get_nowait()
                            play_audio(first_chunk_file)
                            playback_queue.task_done()
                        except queue.Empty:
                            pass

                    break 
            
            time.sleep(POLLING_INTERVAL)
            
        except requests.exceptions.RequestException as e:
            print(f"\nエラー: LLM応答ポーリング中に通信エラーが発生しました: {e}")
            break
        except json.JSONDecodeError:
            time.sleep(POLLING_INTERVAL)
            continue
            
    # 4. 会話履歴の更新と終了処理
    
    CONVERSATION_HISTORY.append({"role": "user", "content": current_prompt})
    if full_response:
        CONVERSATION_HISTORY.append({"role": "assistant", "content": full_response})
    
    reset_llm_context() 

    # キューが空になるのを待つ
    playback_queue.join()
    
    # 一時ファイルディレクトリの削除
    if os.path.exists(TEMP_FILE_PATH) and not os.listdir(TEMP_FILE_PATH):
        os.rmdir(TEMP_FILE_PATH)
            
    return full_response

# ===============================================
## 4. メイン処理 (継続会話ループ付き)
# ===============================================
if __name__ == "__main__":
    
    # --- 1. 再生ワーカーの開始 ---
    worker_thread = threading.Thread(target=playback_worker)
    worker_thread.start()
    print("システム起動。再生ワーカーをバックグラウンドで開始しました。")
    
    # 起動時にLLMコンテキストをリセットし、システムプロンプトを適用
    reset_llm_context() 

    try:
        while True:
            # 2. 音声認識を実行
            user_prompt = transcribe_audio()
            
            print("--- ユーザーの質問 (STT) ---")
            print(user_prompt if user_prompt else "音声が認識されませんでした。")
            print("---------------------------\n")

            if not user_prompt:
                print("音声が認識されませんでした。再度お話しください。")
                continue
            
            # 終了キーワードのチェック
            if user_prompt in ["終了", "さようなら", "おしまい"]:
                print("会話を終了します。")
                break
                
            # 3. 認識したテキストをLLMに入力し、ポーリング & 再生
            stream_and_speak(user_prompt)
        
    except KeyboardInterrupt:
        print("\nユーザーにより処理が中断されました。")
        
    finally:
        print("システムのシャットダウン処理中...")
        # --- 4. 再生ワーカーの停止 ---
        SHOULD_STOP_WORKER.set()
        worker_thread.join()
        
        # 最終的なクリーンアップ
        if os.path.exists(WAVE_RECORD_FILENAME):
            os.remove(WAVE_RECORD_FILENAME)
        if os.path.exists(TEMP_FILE_PATH) and not os.listdir(TEMP_FILE_PATH):
            os.rmdir(TEMP_FILE_PATH)
        
        print("全体の処理が完了しました。")
		
ずんだもん以外にしたい場合は、SPEAKER_IDを変更した上でをシステムプロンプトを調整してみてください。
(env) $ python speakllm.py

5秒間の録音が始まったらマイクに向かって喋ってみてください。

🎙️ Recording for 5 seconds. Speak now...

ずんだもんの声で返答がスピーカーから聞こえてきたら成功です。
おつかれさまでした。

備考

メモリ消費について

ざっくりhtopで計測した結果

システム 動作 メモリ消費量
LLM8850 起動(モデル転送)時 1.7GB
起動後 0.9GB
VOICEVOX 音声リクエスト時 0.2GB〜1GB以上
 ※テキスト量に依存
Faster Whisper 初回起動後 0.6GB

トータルのメモリ使用量(OS使用含む)は、LLMモデル転送時に2GBちょっと、会話中は2GB前後というところでした。
Raspberry Pi 5の2GBモデルでは足りず、4GBモデルでなら動くかもしれない、というところです。

読み上げ速度について

本当はもう少し早く喋らせたいところですが、Qwen3-4Bモデルの生成速度が3.5〜4tokens/sec程度のため、若干読み上げに追いついていませんでした。
実行スクリプトで読み上げ速度を0.9にしてちょうどよい速度バランスになりました。

継続会話について

おしゃべりといいつつ一方的なユーザー発話→LLM解答の繰り返しになってしまいました。
これはLLM解答が終わる度にリセットリクエストを送っているためです。
LLM8850側でトークンキャッシュがあるらしいのですが、やたら小さいのかすぐオーバーフローしてしまっていました。
何かオプションで増やせるかもしれません。

[E][SetKVCache][ 627]: precompute_len(388) + input_num_token(258) > _attr.prefill_max_kv_cache_num_grp[3]
2
2
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
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?