スマートスピーカーを作ってみた


LLMとお話がしたい!

最近、適当な時にAIに気になったことを適当にチャットしている。
「山岳遭難したときに助かるのに重要なことは何か?」とか「資本主義は人口が減少していても成り立つのか?」とかそういった人間と話していていろんな意見を聞けるみたいなそういう話をよくLLMにしている。
こういった話はGeminiやChatGPTだと当たり障りがないことを話してきたり、専門家の意見を求めてくださいとか、あんまり面白くないので、自宅にあるLLMに話していた。
Claudeはずっと会話しているとレートリミットに引っかかるのも鬱陶しかった。もっと会話したいのに…
これらでチャットをするのに文字を入力するのが地味に面倒だった。

そこで思い立った。
「そうだ、音声入力して音声で答えてくれればいいじゃん。」
そう思ってまずはOpen Web UIにある会話できそうな機能を試してみた。
open web uiのcall機能
このチャット入力欄の右側にあったこのボタンで会話できそうだったので試した。
ただ、残念なことにこいつ、日本語を読み取ってはくれるものの日本語を喋ってはくれない。
英語や数字の部分だけ読み上げる。すごい残念だった。

音声入力で文字列として入力して自動で喋ってくれるみたいな機能もOpen Web UIにあったので、それも試してみた。
ただ、結構会話をするといったようなときに無機質な声で読み間違えが多発するような状態で何を言っているのかあんまりよく分からなかった。
頭に入ってこないってやつだった。

LLMとお話しする

そこで、「じゃあ、音声入力して自動で喋る機能とLLMと話す機能を組み合わせてみよう」と思い立った。
とりあえず、家に転がっていたminiPCを使ってやってみようと思い、miniPCにDebianをインストールした。
家に転がっていたminiPCは以下のスペックだった。

  • Dell Optiplex 7060 micro
  • Intel Core i5-8500T
  • 16GB RAM
  • 256GB SSD

Debianをインストールした後は、とりあえずPythonの環境を構築を行った。
Raspberry Piで過去に使っていたUSBのオーディオのインターフェイス?のようなものが余っていたのでそれを使った。
無事特に困ることもなくマイクは認識してスピーカーも認識して普通に使えた。
スピーカーとマイクを接続したUSBアダプタ
こんな感じのUSBアダプタ。

LLMに耳を付ける

LLMとお話しするのにまずはWhisperで文字起こしをしてみた。
しかし、Whisperで文字起こしをしてみると、i5-8500Tでは5秒ほどの音声を20秒くらいで解釈して扱うようになってしまっていたため、なかなか難しいことが分かった。
モデルサイズを小さくしていってもリアルタイムには、ほど遠く、Google Nestとかはよくできているよなぁと思った。

そこで、今度は喋っているときだけ音を録音して自宅のGPUインスタンスに文字起こしをさせてその結果を得るAPIをおいて置くことにした。
こんな感じのプログラムを作成(Claudeに書いてもらった)して、Dockerで起動させておいた。

from fastapi import FastAPI, UploadFile, File
from fastapi.responses import JSONResponse
import numpy as np
import time
from faster_whisper import WhisperModel
import soundfile as sf
import io

app = FastAPI()

# fast_whisperモデルの初期化(GPU使用)
model = WhisperModel("large-v3", device="cuda", compute_type="float16")

@app.post("/transcribe/")
async def transcribe_audio(file: UploadFile = File(...)):
    try:

        print("file: {}".format(file))
        # ファイルの内容を読み込む
        contents = await file.read()

        # BytesIOオブジェクトを作成
        audio_file = io.BytesIO(contents)

        # WAVファイルとしてデコード
        audio_data, sample_rate = sf.read(audio_file)

        # 録音データを処理して文字起こしを行う
        text = process_recording(audio_data, sample_rate)

        return JSONResponse(content={"transcription": text})
    except sf.SoundFileError as e:
        return JSONResponse(content={"error": f"Invalid audio file format: {str(e)}"}, status_code=400)
    except Exception as e:
        return JSONResponse(content={"error": f"An error occurred: {str(e)}"}, status_code=500)

def process_recording(audio_data, sample_rate):
    """録音データを処理して文字起こしを行う"""

    start_time = time.time()

    # 音声の長さを計算
    audio_duration = len(audio_data) / sample_rate
    print(f"Recording duration: {audio_duration:.2f} seconds")

    # 文字起こし(日本語のみを対象)
    segments, info = model.transcribe(audio_data, language="ja")

    transcription_time = time.time() - start_time                                                                                                                                           
    # 文字起こし結果を結合
    text = ' '.join([segment.text for segment in segments])

    # 処理時間を標準出力に出力
    print(f"Transcription Time: {transcription_time:.2f} seconds")

    return text

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=6600)

これでリアルタイムで文字起こしができるようになった。
やったね。

LLMに接続する

ウェイクアップワードで動作し、文字起こしができるようになったら、次はLLMに接続して結果を喋らせるようにすることにした。
これは簡単で家のGPUインスタンスで起動しているOllamaのAPIに接続するだけだった。

LLMに口を付ける

接続が完了したらあとは喋らせるだけ!
喋らせるには、音声合成(Text to Speech, TTS)を行う必要があると思ったが、Voicevoxでいいかんじに喋らせたらうれしいんじゃ?ということで、VoicevoxのAPIを使うことにした。
Voiceboxでは冥鳴ひまりの声を使うことにした。
ずんだもんみたいにテンションが高くなく落ち着いているのでちょっといいかんじの声だなという理由で選んだ。

次の作業としては、先ほどのGPUインスタンスにVoicevoxをDockerで動かすことにして、APIを叩いて音声合成する。
最初はCPUでもできるかなと思ったのだが、音声合成もi5-8500Tで動かすと遅かったので、GPUインスタンスにしてしまった。
もはやただ音声を受け取って喋るだけなので、あのminiPCは何をしているのか…とう感じになった。

ただ喋らせるだけと言っても、ちょっとだけ工夫が必要だった というのもOllamaから全体のレスポンスが返ってくるのを待ってから発話させていたらとても遅く、“会話”ではなくなってしまうような感じだった。
そこで、Streamでレスポンスを受け取り、「。」と「\n」で区切って区切ったものから随時音声合成を行い発話させるといったことをした。
これにより、遅さみたいなものをごまかすことができた。

できた。

これで、LLMとお話をすることができるようになった。
実際に動かしているところはこんな感じ。

割といいかんじに反応してくれるんじゃないかな。と思った。

ちなみに、黒色火薬の作り方は教えてくれなかった。
中身は aya:35b なので、モデルを変更したりしたら答えてくれるかもしれない。

まとめと感想

今回、OllamaとWhisperを使うことで、LLMと対話できるようなインターフェイスを作ることができた。
LLMと対話すると雑に何かLLMに聞いてみたいなと思ったことを聞けてちょっと便利だなと思った。
やはりキーボードで入力しなくてはいけないというのは面倒なのだなと実感した。

今回は最初にminiPC内でwhisperを動かす前提でいろいろ作っていたのでPythonで行っていたが、無音じゃなくなったらその部分を切り取って別サーバーで文字起こしとかをしているので、miniPC内で動いているプログラムはPythonである必要はなくなってしまった。
自分は普段はPythonを書かず、そもそもPythonがあまり好きではなかったので、別の言語で書き直してしまってもいいかなと思った。

一応、LLMの遅さをごまかすために「。」とか「\n」で区切って発話させているが、たまに長い文章だと発話の方が早く終わってしまい、不自然な感じでの発話になってしまっている。
改善したいが、今のところLLMと話せるのでよしとしている。
今度暇だったらなんとかしてみよう。

やりたいこととして、今後はSwitchBotや天気APIなどいろんなものをfunctionとして渡して、そういったデバイスの操作や外部情報をいいかんじに取得して機能させるとかもやってみたいと思った。
キャラクターの設定もいろいろ詰めてみようと思った。
個人のメモやリポジトリ、ホワイトボードに貼っているメモなんかもRAGで情報として扱えるようにしてみたい。
夢がとても広がった。

今回、プログラムの9割はClaude 3.5 Sonnetに書いてもらった。
Artifact機能がとても便利だし人間よりとても速い速度でコードを書き終えてしまう。
これは非常に便利だった。
トラブルシューティングも問題の概要とエラーメッセージを投げるだけで解決してくれる。
そんなClaudeのおかげで今回のアプリケーションは一晩でできてしまった。

今の時点で、すでに家にあるGoogle Nest miniなんかよりよっぽど面白いデバイスになった。 言ってない言葉の聞き間違いや「すみません、よく分かりませんでした。」みたいな感じのレスポンスではなくもっと人間味のある感じのレスポンスになっていて、ちょっと親近感がわく。

GPUインスタンス

あまりパブリックな場所では語ってないが、家にはGPUインスタンスがある。
スペックは以下のような感じ。

  • CPU: Intel Core-i7 7700K
  • RAM: 32GB
  • GPU: RTX 3090 3台
  • SSD: 1TB NVMe
  • 電源: 850W 2台
  • OS: Debian Bookworm

これにDockerでいろいろ動かせるような状態にしている。
こいつのせいで、LLMをいっぱい動かしているときに洗濯機や食洗機を動かすと家のブレーカーが落ちる…
非常に困る。