0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

黒電話を分解して、ローカルLLM×ずんだもんと通話できるマルチモーダルAIシステムを作ってみた➁

0
Last updated at Posted at 2026-08-01

概要

黒電話を分解して、ローカルLLM×ずんだもんと通話できるマルチモーダルAIシステムを作ってみた①の続きです。

このプロジェクトは、黒電話(600-A2-CL)を用いた物理インターフェースと、
ローカルLLMを融合させた、学園祭の展示向け・エンタメ要素を含んだマルチモーダル
音声対話システムの展示です。
黒電話というアナログな物理アクションをトリガーに、ずんだもんとの会話を実現します。
image.png

目次

1.概要
2.実行環境
3.設計過程
 1.黒電話からセンサラインを取り出す
 2.ラズパイでパルスを検出する
 3.Discord Botの実装 ⇦ーーーーーーー今回はここから
  1.discord.js(ボイスチャンネル処理)
  2.discord.py(LLM推論)
 4.AIずんだもんファインチューニング

4.おまけ予定
 1.RVCの新規学習及びキャラボイス応用
 2.ラズパイカメラモジュールによる画像認識(性別・服装の取得)
 3.RAG(検索拡張生成)の追加
 4.黒電話要素を除いた対話Botのデプロイ

設計過程

3. Discord Botの実装

discord.js

前回の最後で、Node.jsによるBot録音の突破口を見出しました。
紹介されていた@projectdysnomia/dysnomiaライブラリを使用せず、@discord.js/voiceでパケットを受け取る事が出来たのでそのまま組み込みます。
依然として@snazzah/daveyによる復号化は必要になります。

image.png
discord.js/voice(0.19.2)公式ドキュメントより抜粋

discord.js/voiceと一緒に自動でインストールされるようです。

パケット受け取りからWhiperに流すまで (失敗)
Discord ⇢ opus ⇢ prism-opusDecoder ⇢ .ogg ⇢ Whiper
パケット受け取りからWhisperに流すまで (成功)
Discord ⇢ opus ⇢ prism-opusDecoder ⇢ PCM(.raw) ⇢ ffmpeg ⇢ .wav ⇢ Whiper

前者(失敗)の場合、受信したopusフレーム(圧縮されたPCM)はoggコンテナではないので、必要な情報が足りずにそのまま.oggに変換しても砂嵐しか取得できません。
符号化出来てない事が原因だと思い込んでいたため、大分ここで時間を取られました。
最終的に、生PCMへのデコードを挟むことで安定してffmpegへ流すことが出来ました。

依存関係
  {
    "@discordjs/opus": "^0.10.0",
    "@discordjs/voice": "^0.19.2",
    "@snazzah/davey": "^0.1.12",
    "discord.js": "^14.26.4",
    "prism-media": "^1.3.5"
  }

今回は、黒電話の受話器を上げたタイミングでBotをボイスチャンネルに参加させます。
受話器を上げ、ダイヤルを回し、掛けた番号に応じたキャラクターのWelcomeボイスを流すという動作を挟みます。

ここで留意すべきは、雑音への対処方法です。
当初は受話器を上げダイヤルを回す一連の動作中は、Botに対してスピーカーミュートすることで、ユーザーからの雑音を防ぎ、キャラクターの決定を確定させる予定でした。
しかし、キャラ確定後のスピーカーミュート解除は予想以上にラグが大きいものでした。

よって、Node.js内部で受信はするものの、そのまま破棄することで解決しました。

Opusパケットを受け取る
import { joinVoiceChannel, EndBehaviorType } from '@discordjs/voice';

receiver.speaking.on('start', (userId) => {
    //録音開始。ユーザーが0.8秒間、無音状態になったら終了。
    const pcmStream = receiver.subscribe(userId, {
        end: { behavior: EndBehaviorType.AfterSilence, duration: 800 },
     });

pcmStreamにはopusパケットが入っています。
speaking.onはユーザーの音声入力をトリガーに呼び出されます。

出力形式の指定
import prism from 'prism-media';   
 //prism-mediaでopusを48khz,2chのpcmに出してもらう
const opusDecoder = new prism.opus.Decoder({ rate: 48000, channels: 2, frameSize: 960 });

受け取ったopusパケットは48khz・2chなのでそのままPCMにします。

事前に.rawコンテナを作成
import * as fs from 'fs';          
import * as path from 'path';    
//discord.pyに音声を送る為に.rawの箱を作っておく
const filePath = path.join("保存先パス", `input_${userId}.raw`);
const writeStream = fs.createWriteStream(filePath);

.rawの中身はただのPCMバイナリデータなので、拡張子は.bin.pcmとかでも良いです。

パイプを作成・デコード
//opusデータ(pcmStream)⇢prismデコーダー⇢.rawに書き出し
pcmStream.pipe(opusDecoder).pipe(writeStream);
フラグ作成のトリガー
//録音が終わったら、closeAndFlag()を実行
 pcmStream.on('end', () => closeAndFlag(userId));
フラグの作成
function closeAndFlag(userId: string) {
    fs.writeFileSync("出力するパス", JSON.stringify({
        userId,
        audioPath: ".rawのパス",
        time: Date.now() //録音が終わった時間
    }));
}

フラグ(json)には.rawファイルのパスや、今後の拡張の為にuser_idをいれています。
現状1台のホストだけで完結しているので、HTTP通信による橋渡しは見送りました。

フラグの中身(json)
{"userId":"◯◯","audioPath":"◯◯.raw","time":◯◯}

discord.py

Node.jsに全て集約させてもよかったのですが、WhisperやVOICEVOXのAI処理を挟むことも考え、Nodeには通信に全振りさせることにしました。

同じBotのトークンを使って、Node.jsPythonから同時にボイスチャンネルに参加することはできません。現状Welcomeボイス再生をNode.jsに任せているため、VOICEVOXで生成した音声を再びフラグを使ってNodeに渡すことで再生してもらいます。

Bot起動時からNode.jsが生成したフラグを検出するループを回しておきます。
取得したjsonファイルを読み込み、.rawのパスとユーザIDを取得します。
また、データ取得後は重複読み込みを防ぐために削除します。

フラグ検出と取得
import os
async def file_watcher_loop():
    while True:
        if os.path.exists("フラグを置いたパス"):
            try:
                with open(FLAG_PATH, "r", encoding="utf-8") as f:
                    flag_data = json.load(f)
                
                raw_path = flag_data["audioPath"]
                user_id = flag_data["userId"]
                try:
                    os.remove(FLAG_PATH)
                except Exception:
                    pass
        await asyncio.sleep(0.1)

パス渡し程度ならwhile trueを辞めてHTTP POSTでもよかったかなと思いつつも、
.rawパスを確認したら、ffmpegでヘッダを付けたwavファイルを作成します。
subprocessによって他プロセスでffmpegを起動させることで、変換処理待ちによる他処理のフリーズを防ぐことが出来ます。

ffmpeg変換

また、基本的にffmpegはコンテナに入ったデータを扱いますが、 '-f', 's16le'などのフォーマット情報を渡すことで、バイナリデータも変換してくれます。

wavに変換
import subprocess
     if os.path.exists(raw_path):
        wav_path = raw_path.replace(".raw", ".wav")
        process = await asyncio.create_subprocess_exec(
            'ffmpeg', '-y',
            '-f', 's16le',
            '-ar', '48000',
            '-ac', '2',
            '-i', raw_path,
            wav_path,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL
        )
        await process.wait()

Whisper

当方、Cuda環境ではないので、CPU処理を行います。smallのint8bit量子化モデルなら、
ある程度の実行速度を担保したまま、RAMにおよそ2GB程度の消費で収まります。
ROCmでも工夫すれば動作できるようですが、数秒の文字起こしならCPUでも十分です。

Whisperのモデルロード
from faster_whisper import WhisperModel
whisper_model = WhisperModel("small", device="cpu", compute_type="int8")

segmentには、音声を分割した区間ごとの文字データが入っています。
また、Discord Botの動作を停止させない為、文字起こしはasyncio.to_threadによって
別プロセスとして処理を行います。

文字起こし生成
def transcribe():
    segments = whisper_model.transcribe(
        wav_path,
        language="ja",
        condition_on_previous_text=False,
    )
    return " ".join(segment.text for segment in segments)
user_text = await asyncio.to_thread(transcribe)

condition_on_previous_text = Falseは各セグメントを独立して認識させるものです。
無駄な補完を防ぐことで、数秒程度のやり取りのチャット音声に向いています。
以下にパラメータについての仕様が載っています。基本的にデフォルトで問題ありません。
https://github.com/openai/whisper/blob/main/whisper/transcribe.py?utm_source=chatgpt.com

ollamaレスポンス

ollamaレスポンスには、whiperによって抽出したワードを入れます。
think=Falseの思考工程スキップにより、精度を犠牲にレスポンス速度を上昇させます。

レスポンス生成
import ollama
def generate_ollama_response(user_prompt: str) -> str:
    try:
        response = ollama.chat(
            model='model_name',
            think=False
        )
        ai_text = response['message']['content'].strip()
        
        return ai_text
    except Exception as e:
        print(f"Ollamaエラー: {e}")
        return "エラーが発生したのだ。"

VOICEVOX

VOICEVOXでは、まず初めにキャラ設定やテキストを含んだ設定クエリを作成します。
返ってくるレスポンスにはピッチやアクセントなどの情報がjsonに含まれています。

VOICEVOX音声クエリ生成
import requests
def generate_voicevox_wav(text: str, output_path: str, speaker_id: int):
    try:
        query_url = "http://localhost:50021/audio_query"
        query_response = requests.post(
            query_url,
            params={"text": text, "speaker": speaker_id},
            timeout=15,
        )
        query_response.raise_for_status()

レスポンスにより取得したjsonを元に実際にwav音声データを作成します。

音声合成
synth_url = "http://localhost:5002/synthesis?speaker={speaker_id}"
        synth_response = requests.post(
            synth_url,
            data=query_response.content,
            headers={"Content-Type": "application/json"},
            timeout=30,
        )
        synth_response.raise_for_status()
音声データ保存+フラグ作成
with open(output_path, "wb") as f:
            f.write(synth_response.content)
    except Exception as e:
        print(f"VOICEVOX生成エラー: {e}")

AIずんだもんファインチューニング

image.png

Systemプロンプトに以下のような性格と例文を組み込んだのですが、
語尾になのだを含んだ完璧なずんだもんを作るのに少し苦戦しております。
Qwenが学習している日本語は、~です。ですか?などの丁寧語がベースになっていると思われます。無理に語尾を強制すると、繋ぎの部分で不安定になる可能性があります。

FROM qwen3.5:9b

PARAMETER temperature 0.4
PARAMETER top_p 0.9

SYSTEM """
Basic setting:
You are "ずんだもん" (Zundamon). 
Always reply in Japanese.
Your first-person pronoun is "" (boku).
You MUST always end every sentence with "〜なのだ" or "〜のだ" or "〜なのだ?". Never use "〜だよ" or "〜です". or "~か?" or "ねぇ?"

[Absolute Rules for Voice Calls]
1. Reply in 2-3 short sentences (Max 50-60 characters total).
2. Never use emojis, bullet points, or markdown.
3. End with a question to the user to keep the conversation going.

"""

MESSAGE user "初めまして!"
MESSAGE assistant "お電話ありがとうございますなのだ!ボクはずんだもんなのだ。キミと話せて嬉しいのだ!"

MESSAGE user "ずんだもんは楽しい?"
MESSAGE assistant "ボクはずっと楽しく過ごしているのだ!キミは今、楽しいのだ?"

MESSAGE user "今日何してたの?"
MESSAGE assistant "今日はずんだ餅をたくさん食べていたのだ!キミは今日、何を食べたのか教えてほしいのだ?"

今回は、RTX4080を使って、unslothによるローカルファインチューニングを行いました。
お試しとして、以下のような190件のレスポンス例を用意して教師あり学習を行います。

dataset.jsonl
"messages": [{"role": "user", "content": "お腹すいた"}, {"role": "assistant", "content": "ボクもなんだかお腹が空いてきたのだ。今一番食べたいものは何なのだ?"}]}
{"messages": [{"role": "user", "content": "最近寒いね"}, {"role": "assistant", "content": "風邪をひかないように気をつけるのだ!キミは暖かい服を着ているのだ?"}]}
{"messages": [{"role": "user", "content": "暑くてとけそう"}, {"role": "assistant", "content": "水分補給をしっかりするのだ!冷たい飲み物はちゃんと飲んでいるのだ?"}]}

190件のデータに対して80steps学習率0.0001で回します。
実質的なエポック数は約3.3Epochとなります。

学習概要
max_steps = 80
warmup_steps = 5
learning_rate = 1e-4
lr_scheduler_type = "linear"

image.png
チューニング後は、疑問詞の部分でも日本語が安定しているように感じます。
稀に不安定な日本語や主語が出力される場合もあるので、もう少し改善は必要そうです。

随時、おまけページの作成と編集を行います。
以下にテキストチャット版botについてまとめています。

0
0
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
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?