1
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システムを作ってみた①

1
Last updated at Posted at 2026-08-01

概要

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

黒電話の受話器を取り、ダイヤルを回してVOICEVOXのキャラクターを選択。
受話器越しに話しかけることで、内容に応じてキャラクターボイスで返答を行います。

Discord Botを挟むのは、discord.py(.js)が提供するAPIが豊富なのと、
Krisp(ノイズキャンセリング)が優秀だからです。
また、複数人が参加したVC内でAIと対話が出来るBotとしての運用も考えているからです。

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のデプロイ

目標

1.マルチモーダルへの理解を深める。
2.ラズパイを通じて簡単なコマンドを理解する。
3.レポジトリの作成と、時間があればDockerでパッケージングを行う。
4.今後の研究への基礎固めに繋げる。

実行環境・構成

Ryzen 7 5700X3D
Radeon RX 9070 XT (16GB VRAM)

python(3.12.2)(.venv)
TypeScript(6.0.3)
discord.py - 2.7.1
discord.js - 14.26.4
faster-whisper - 1.2.1
VOICEVOX - 0.25.2

LLM - Qwen3.5-9B (Q4_K_M)
   Qwen3-VL-4B(画像認識)

Raspberry Pi 5 (RAM 8GB / Raspberry Pi OS 64bit)

設計過程

1.黒電話からセンサーラインを取り出す

使うものは以下のとおりです。

・はんだごて一式
・適当な銅線(今回は余っていたスピーカーケーブルを代用します)
・AUXケーブル(100均一の有線イヤホンでも代用可)
・デジタルテスター(ラインを選出するのに使います)

黒電話から取り出すライン(信号)は以下の4つです。

  1. 受話器内蔵のスピーカーライン
  2. 受話器内蔵のマイクライン
  3. 受話器の上げ下げを検知するフック
  4. ダイヤルのカウント

1. 受話器から出ている線をWindowsパソコンに接続するため、3極プラグに変換します。
AUXケーブルを切断し、中からR,Lどちらかのライン・グランドラインを引っ張ります。
受話器から出ている二本の線の先の端子を切断し、はんだを流して接続します。

2.感圧板からは3本のラインが出ています。内2本のラインをテスターの導通モードで検出し、受話器を置いた際にクローズする組み合わせを選びます。銅線をはんだで繋げてラズパイと接続するラインを作ります。

3.ダイヤルからは4本のラインが出ています。同じく2本の組み合わせを選び、ダイヤルを回し、戻ってくるタイミングで選択したダイヤルの回数分クローズするものが正解です。
こちらも同じく銅線に繋げ、ラズパイに接続するための距離を稼ぎます。

2.ラズパイでパルスを検出する

実際にブレッドボードに銅線を接続し、ラズパイにパルスを送ります。

持ってきた銅線は、ターミナルブロックに接続するのが最適です。
使用するGPIOピンにそれぞれダイヤルライン、受話器感圧ラインを接続しています。

カーボンマイクの流用

※前提として、カーボンマイク利用には外部電源による駆動が必要だと思っていましたが、実際にはパソコンのマイク入力端子のプラグインパワーだけで実用可能でした。
一応、外部給電を利用した駆動についてもまとめました。

当時のカーボンマイクには、電話線から流れてきた約50mA程度の電流が使われていました。
今回使うマイク端子にはオペアンプ回路が含まれているため、増幅することで小さな電流でも実用可能になります。

PCマイク入力端子単体 カーボンマイク単体 接続時
V 3.8V 0.4V
Ω 2.8kΩ 200~300Ω

実測値から求めても分かる通り、実際に1mA程度しかカーボンマイクには流れていません。

I = (3.8V-0.4V)/2800Ω = 1.21mA

外部電源ありの検証

今回のテストでは、400Ω挟んでテストします。10mA程度ですが、試してみます。

ラズパイ直流5Vをカットして、声(交流成分)を取り出すためにコンデンサを挟みます。
コンデンサの容量を検討するにあたり、周波数特性と容量リアクタンスを確認します。
これは、RCの組み合わせによりハイパスフィルターが生成されているからです。

カットオフ周波数は、fs = 1/2πRCで求められます。

カットオフ周波数 容量(1μF)を想定
fs = 1/(2*3.14*2800*(1*10^-6)) ≒ 56.8Hz

カットオフ周波数は約56Hzと求められました。(56Hz以下はカット)
男性の最も低い成分が100Hz程なので、しっかりと声をPCに届けることが出来ます。

結論、ラズパイ5V給電による動作を確認しました。
しかし、ラズパイから出る高周波数ノイズが想定よりも大きく、そのまま使うためにはソフトウェア側のノイズゲートなどを使う必要がありそうです。

9V電池給電による動作も確認しました。
純粋な直流給電なので、ノイズが少なくて現実的だと思いました。

コード実装

今回は、センサー(受話器の状態)をトリガーとした動作を想定しているので、
イベント駆動型を用います。以下の2行によって、GPIOを介した検出を行います。

GPIO.FALLINGでは3.3vにつないだダイヤルラインが0Vに降下したタイミング(閉路)
即ちダイヤルをカウントする回数分dial_pulse_callbackをコールバックします。
GPIO.BOTHは導通と閉路の両方をトリガーとします。
また、ダイヤル信号を受信する条件に受話器が上がっている状態を加えます。

トリガー検出
import RPi.GPIO as GPIO

GPIO.add_event_detect(17, GPIO.FALLING, callback=dial_pulse_callback)
GPIO.add_event_detect(27, GPIO.BOTH, callback=hook_switch_callback)

黒電話の仕様として、20パルス/秒、即ち1ダイヤル間隔50ms程度が理論値となります。
チャタリング判定を0.06秒以内に設定した場合、次のパルスを無視してしまうため、
正確な回数をカウント出来ません。よって、20msをチャタリング判定の境界に設定します。

ダイヤルカウント
import time
pulse_count = last_pulse_time = 0
timer = None

def dial_pulse_callback(channel):
    global pulse_count, last_pulse_time, timer
    
    if not is_hooked_up: #初期は置かれている状態のFalseをいれます。この場合はスルー。
        return

    current_time = time.time()
    #0.02秒以内に連続してクローズを検知した場合はチャタリングと判断してスルーします。
    if (current_time - last_pulse_time) < 0.02:
        return
        
    pulse_count += 1  #パルス回数を+1します。
    last_pulse_time = current_time
    
    #クローズを検出したタイミングで、毎回タイマーをリセットします。
    if timer is not None:
        timer.cancel()  #タイマーストップ
   

最後のカウントより0.4秒以上経過した場合をトリガーにthreading.Timer()内で
finish_dialingを呼び出します。パルスが検出されるたびに0.4秒タイマーを再設定します。
GPIO割り込み処理を阻害しないため、別スレッドで処理させるという意味合いもあります。

スレッド作成処理
import threading
# 最後のパルスから0.4秒以上経過でカウント終了判定
timer = threading.Timer(0.4, finish_dialing)
timer.start()#もう一度タイマー開始
受話器の状態判定
is_hooked_up = False

def hook_switch_callback(channel):
    global is_hooked_up
    time.sleep(0.05) #0.05秒間、チャタリングが収まるのを待ちます。
    
    current_state = GPIO.input(HOOK_PIN)  #上がっていたら1、置いてたら0が入ります。
    
    if current_state == 0: 
        if not is_hooked_up:
            is_hooked_up = True
            print("受話器が上がってるよ")
    else:  # ④ それ以外(1)なら置かれた
        if is_hooked_up:
            is_hooked_up = False
            print("受話器が置かれたよ")

チャタリングを考慮して、10回以上のパルスの場合は0に確定させます。
1<10回のカウント内で偶発的にチャタリングが発生した場合は許容するしかありません。
多分これはアナログならではの劣化だと思います。たまに変なノイズが混ざるのかも。

カウント終了後の処理
def finish_dialing():
    global pulse_count, timer
    
    if pulse_count >= 10:
        digit = 0
    elif 1 <= pulse_count <= 9:
        digit = pulse_count
    else:
        digit = None
        
    if digit is not None:
        print(f"\n番号: {digit}")
    
    # カウントをリセット
    pulse_count = 0
    timer = None
結果1
受話器が持ち上げられました
番号: 0 パルス回数 10回
結果2
受話器が持ち上げられました
番号: 5 パルス回数 5回

ラズパイでbotを稼働すると、コードを書き直す際に毎回起動し直すのが面倒なので、webhookを使うことにしました。一方的に通知を送るだけなので利便性も良いです。
チャンネルで作成したwebhook用のurl経由で外部からjson形式でメッセージを送信します。

webhook送信
import requests
def send_discord_webhook(message):
    payload = {"content": str(message)}
    try:
        requests.post(DISCORD_WEBHOOK_URL, json=payload)
    except Exception as e:
        print(f"Webhook送信エラー(テキスト): {e}")

ヘッダーにはjson形式であることと、送信元がMozilla/5.0であることを記します。
python経由での自動アクセスはセキュリティ的にサーバーに弾かれてしまうようで....
よってUser_Agentで偽装することで、そのフィルターを回避するみたいです。

最後にカウント終了後に番号をsend_discord_webhookに込めて送信します。

ダイヤルカウント
def finish_dialing():
ーーーーーーーーーーーーーーーーーーーーー
send_discord_webhook(digit)

3.Discord Botに導入

前提として、既にTOKENを取得しサーバーにBot参加が参加しているものとします。

ボイスチャットにユーザ・Botを参加させ、ユーザーから受け取った音声をBot側で受け取る構造を考えています。フォーク版ライブラリであるpy-cordには録音機能が付いているので、こちらを検討します。

当初は録音機能を備えたpy-cordを検討していましたが、2026年3月のDAVEによるエンドツーエンド暗号化必須要件にライブラリが追いついていないようです。
厳密には対応しているらしいですが、不安定なので別アプローチを取ります。

しょうがないので、その場で録音して自宅PCに送る方法を検討します。
ポート開放は避けたいので、ngrokを使用します。これはURLによるHTTP POSTで音声送信を、ngrokを経由して送るというものです。Webhookの仕組みに似ています。
しかし料金が掛かるのと、学内wifiはテザリング禁止なこともあり、トンネルを利用した外部との通信を行うngrokの利用はグレー寄りなのかもしれません。

ローカルで録音してDiscordサーバーに音声を送る手もありますが、将来的に複数人が参加しているボイスチャット内での対話を実現したいので、あまり現実的ではありません。

悩んでいたところ、以下のような記事を見かけました。

要約すると、Node.js上で@Project DysnomiaライブラリによるDAVA暗号化された音声パケットの受信と、@snazzah/daveyによる復号化を組み合わせて録音しているみたいです。

次に続く

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

1
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
1
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?