Raspberry Pi 5を触ってみたかったので、ラズパイでスマートスピーカー自作にプラスαを加えて、ローカルTTSモデルを介して好きな声で返してくれるものを作ってみました。
実際のプロダクトを開発した経験はないので、処理の細かい部分は拙いところが多いと思いますが、個人の思い出としてまとめようとおもいます。
構成
音声入力から出力までのフローは以下のようになっています。
各フローの説明は後述します。
使用機材
ラズパイです。8GBモデルを買いました
Raspberry Pi 5 は発熱量が過去モデルよりも多いとのことで、ケースについているファンをつけたほうが良いとのことでした。
別売りですので、購入する必要があります。
ストレージ用のMicroSDカードです。品質が良いのを買いました。
モニターに繋いでGUIで使いたい場合必要です。
無くても大丈夫です。
音声を出力するためのスピーカーです。
USBに接続するだけで使えると思いましたが、USBは給電用でした。ステレオミニジャックを指して音を出力します。
USB接続で音が出るエレコム PCスピーカ- USB接続 MS-P08USB2BKのほうが良いと思います。
USBに接続する超小型のマイクです。音声入力用です。
- Native Instruments Audio 2 DJ (オーディオインターフェイス)
Raspberry Pi 5にはステレオミニジャックの端子がありませんので、エレコム スピーカー MS-P08UBKのミニジャックをこのオーディオインターフェイスに挿しました。
Audio 2 DJとRaspberry Pi 5をつなぐUSBケーブルです。
- TTS モデル ローカル動作用パソコン
RTX4070 Ti Superを搭載したデスクトップパソコンです。
TTSモデルはGPUで処理します。
Raspberry Pi 5 セットアップ
立ち上げまで
適当なパソコンで上記ページからRaspberry Pi OSのインストーラーをダウンロードし、MicroSDカードに書き込みます。
この時MicroSDカードをパソコンに認識させる必要があるので、挿入口が無い場合はUSBへ変換する機器が必要です。
Raspberry Pi OS (64-bit)がRecommended Recommendedとめちゃくちゃ推されていたので選択
ホスト名・ユーザー名とパスワード・Wi-Fi設定・ロケール設定を行います。
スクリーンショットを取り忘れましたが、「サービス」の項目にはRSA暗号の秘密鍵と公開鍵を生成して、SSH接続できるようにする項目があります。
Raspberry Pi 5 にMicroSDカードを挿し込み、起動できました!
初期設定
キーボード設定
パイプ文字が打てない状態になっていました。Mouse and Keyboard SettingsでLayoutをOADG 109Aにして解決。
Python環境作成
poetryで環境構築をしていきます。
$ pipx install poetry
オーディオ周りにPyAudioを使うので、必要なソフトウェアをインストールします。
$ sudo apt-get install portaudio19-dev
オーディオ周り
再生機器が認識しているかどうかを確認します。
$ lsusb # USBデバイスの確認
Bus 004 Device 001: ID 1d6b:0003 Linux Foundation 3.0 root hub
Bus 003 Device 002: ID 17cc:041c Native Instruments Audio 2 DJ
Bus 003 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub
Bus 002 Device 001: ID 1d6b:0003 Linux Foundation 3.0 root hub
Bus 001 Device 002: ID 08bb:2902 Texas Instruments PCM2902 Audio Codec
Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub
$ aplay -l # オーディオ再生デバイスの確認
**** List of PLAYBACK Hardware Devices ****
card 0: vc4hdmi0 [vc4-hdmi-0], device 0: MAI PCM i2s-hifi-0 [MAI PCM i2s-hifi-0]
Subdevices: 1/1
Subdevice #0: subdevice #0
card 1: vc4hdmi1 [vc4-hdmi-1], device 0: MAI PCM i2s-hifi-0 [MAI PCM i2s-hifi-0]
Subdevices: 1/1
Subdevice #0: subdevice #0
card 2: Audio2DJ [Audio 2 DJ], device 0: Audio 2 DJ [Audio 2 DJ]
Subdevices: 2/2
Subdevice #0: subdevice #0
Subdevice #1: subdevice #1
# card 2 Subdevice 0 に対して再生テスト
$ aplay -D plughw:2,0 /usr/share/sounds/alsa/Front_Center.wav
音が無事出たらOKです。
PyAudioのデバイス番号を確認します。
import pyaudio
audio = pyaudio.PyAudio()
for i in range(audio.get_device_count()):
print(audio.get_device_info_by_index(i)
{'index': 0, 'structVersion': 2, 'name': 'vc4-hdmi-0: MAI PCM i2s-hifi-0 (hw:0,0)', 'hostApi': 0, 'maxInputChannels': 0, 'maxOutputChannels': 2, 'defaultLowInputLatency': -1.0, 'defaultLowOutputLatency': 0.005804988662131519, 'defaultHighInputLatency': -1.0, 'defaultHighOutputLatency': 0.034829931972789115, 'defaultSampleRate': 44100.0}
{'index': 1, 'structVersion': 2, 'name': 'Audio 2 DJ: - (hw:2,0)', 'hostApi': 0, 'maxInputChannels': 0, 'maxOutputChannels': 2, 'defaultLowInputLatency': -1.0, 'defaultLowOutputLatency': 0.008684807256235827, 'defaultHighInputLatency': -1.0, 'defaultHighOutputLatency': 0.034829931972789115, 'defaultSampleRate': 44100.0}
{'index': 2, 'structVersion': 2, 'name': 'USB PnP Sound Device: Audio (hw:3,0)', 'hostApi': 0, 'maxInputChannels': 1, 'maxOutputChannels': 0, 'defaultLowInputLatency': 0.008684807256235827, 'defaultLowOutputLatency': -1.0, 'defaultHighInputLatency': 0.034829931972789115, 'defaultHighOutputLatency': -1.0, 'defaultSampleRate': 44100.0}
{'index': 3, 'structVersion': 2, 'name': 'sysdefault', 'hostApi': 0, 'maxInputChannels': 0, 'maxOutputChannels': 128, 'defaultLowInputLatency': -1.0, 'defaultLowOutputLatency': 0.005804988662131519, 'defaultHighInputLatency': -1.0, 'defaultHighOutputLatency': 0.034829931972789115, 'defaultSampleRate': 44100.0}
{'index': 4, 'structVersion': 2, 'name': 'hdmi', 'hostApi': 0, 'maxInputChannels': 0, 'maxOutputChannels': 2, 'defaultLowInputLatency': -1.0, 'defaultLowOutputLatency': 0.005804988662131519, 'defaultHighInputLatency': -1.0, 'defaultHighOutputLatency': 0.034829931972789115, 'defaultSampleRate': 44100.0}
{'index': 5, 'structVersion': 2, 'name': 'pulse', 'hostApi': 0, 'maxInputChannels': 32, 'maxOutputChannels': 32, 'defaultLowInputLatency': 0.008684807256235827, 'defaultLowOutputLatency': 0.008684807256235827, 'defaultHighInputLatency': 0.034807256235827665, 'defaultHighOutputLatency': 0.034807256235827665, 'defaultSampleRate': 44100.0}
{'index': 6, 'structVersion': 2, 'name': 'default', 'hostApi': 0, 'maxInputChannels': 32, 'maxOutputChannels': 32, 'defaultLowInputLatency': 0.008684807256235827, 'defaultLowOutputLatency': 0.008684807256235827, 'defaultHighInputLatency': 0.034807256235827665, 'defaultHighOutputLatency': 0.034807256235827665, 'defaultSampleRate': 44100.0}
1番がAudio 2 DJですが、1番を選ぶとエラーになります。Defaultの6番を選ぶことでAudio 2 DJを介して音が鳴ります。これはAudio 2 DJのドライバーを入れていないからだと思われます(Audio 2 DJはLinuxのDriverは配布なし)
次に録音機器の確認をします。
$ arecord -l # 録音機器のデバイスの確認
**** List of CAPTURE Hardware Devices ****
card 3: Device [USB PnP Sound Device], device 0: USB Audio [USB Audio]
Subdevices: 1/1
Subdevice #0: subdevice #0
認識確認後は、以下ページのreport.py
を実行して、録音と再生の確認を行いました。
問題無く自分の声が返ってきたらOKです。
プログラム作成
構成の「音声入力受付」「音声wav生成」の部分のプログラムを書きます。
後程紹介するapp.py
から使われますが、実行してから5秒間録音するようにしています。
import pyaudio
import time
import wave
CHUNK = 4096
CHANNELS = 1
FRAME_RATE = 44100
CARD_NUM = 2
class AudioRecorder:
def __init__(self):
self.audio = pyaudio.PyAudio()
wav_file = None
stream = None
# コールバック関数
def callback(self, in_data, frame_count, time_info, status):
# wavに保存する
self.wav_file.writeframes(in_data)
return None, pyaudio.paContinue
# 録音開始
def start_record(self):
# wavファイルを開く
print("録音を開始します。5秒間のうち何かしゃべってください。")
self.wav_file = wave.open('record.wav', 'w')
self.wav_file.setnchannels(CHANNELS)
self.wav_file.setsampwidth(2) # 16bits
self.wav_file.setframerate(FRAME_RATE)
# ストリームを開始
self.stream = self.audio.open(format=self.audio.get_format_from_width(self.wav_file.getsampwidth()),
channels=self.wav_file.getnchannels(),
rate=self.wav_file.getframerate(),
input_device_index=CARD_NUM,
input=True,
output=False,
frames_per_buffer=CHUNK,
stream_callback=self.callback)
# 録音停止
def stop_record(self):
print("録音を停止します。")
# ストリームを止める
self.stream.stop_stream()
self.stream.close()
# wavファイルを閉じる
self.wav_file.close()
# インスタンスの破棄
def destructor(self):
# pyaudioインスタンスを破棄する
self.audio.terminate()
# 録音を行って、結果のwavファイルを返す
def record_for(self, duration=5, output_filename='record.wav'):
self.start_record()
time.sleep(duration)
self.stop_record()
self.destructor()
return output_filename
構成の「Whisper-1 APIでSpeech to Text」から「音声出力」までのプログラムを書きます。
実際に実行する時は以下のようなコマンドで実行します。
$ poetry run python app.py https://xxxxxxx (ngrokのURL) 2> /dev/null
import json
import os
import tempfile
import wave
import sys
from io import BytesIO
from scipy.io.wavfile import read, write
import pyaudio
import requests
from dotenv import load_dotenv
import openai
from record import AudioRecorder
try:
url_arg = sys.argv[1]
print(f"URL = {url_arg}")
except:
raise ValueError("TTS APIアクセスの為のURLを引数にいれてください。")
load_dotenv()
p = pyaudio.PyAudio()
CHUNK = 1024
CARD_NUM = 2 # arecord -l で確認するスピーカーデバイス
# .envファイルからOpenAIのAPI KEYを持ってくる
client = openai.OpenAI(
api_key=os.environ.get('OPENAI_API_KEY')
)
recorder = AudioRecorder()
if __name__ == "__main__":
# 録音開始
recorded_file = recorder.record_for()
# Whisper-1でAudio to Text
wavfile = open(recorded_file, "rb")
try:
transcript = client.audio.transcriptions.create(
model="whisper-1",
file=wavfile,
language='ja'
)
except openai.APIStatusError as e:
print(f"openai status error. {e}")
raise
# OpenAI GPT4oで会話
chat_completion = client.chat.completions.create(
messages=[
{"role": "system", "content": "あなたはゆずソフトのキャラクター「在原 七海」です。七海ちゃんの口調で回答してください。回答自体はできるだけ短くしてください。"},
{"role": "user", "content": f"{transcript.text}"} # Whisper-1で変換したテキストがここに入る
],
model=os.environ.get('OPENAI_API_MODEL')
)
# 返事が返ってくる
answer = chat_completion.choices[0].message.content
# TTSモデルで七海の声に変換
payload = {"text": f"{answer}"}
headers = {"Content-Type": "application/json"}
# TTSモデルが立ち上がっているサーバーにリクエスト
response = requests.post(f"{url_arg}/run", headers=headers, data=json.dumps(payload))
# レスポンスで200番台のステータスコードが返ってきた場合、wavファイルを保存して再生
if response.ok:
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp_file:
tmp_file.write(response.content)
audio_file_path = tmp_file.name
print(audio_file_path)
with wave.open(audio_file_path, 'rb') as wf:
# Instantiate PyAudio and initialize PortAudio system resources (1)
p = pyaudio.PyAudio()
# Open stream (2)
stream = p.open(format=p.get_format_from_width(wf.getsampwidth()),
channels=wf.getnchannels(),
rate=wf.getframerate(),
output=True # output_device_indexは指定せずデフォルトにする
)
# Play samples from the wave file (3)
while len(data := wf.readframes(CHUNK)): # Requires Python 3.8+ for :=
stream.write(data)
# Close stream (4)
stream.close()
# Release PortAudio system resources (5)
p.terminate()
else:
print(f"Request Failed with status code {response.status_code}: {response.text}")
TTS モデル セットアップ
プログラム作成のコメントアウトで少し書いていましたが、今回使うTTSモデルはmoe-ttsというモデルです。
詳しくは上記ページを見ていただければと思います。
元データ的に個人利用までにとどめておいた方が良いと思います。
ngrok
TTSモデルを起動するパソコンとRaspberry Piを通信するのに使います。
ngrokはローカルPCで稼働しているネットワークを外部公開できるサービスです。
TTSモデルを立ち上げる環境はWindows 10 HomeのWSL2上のUbuntuであり、この場合互いのローカルアドレスは同じサブネットにありません。
Windows 10 ProであればHyper-Vの機能でブリッジすることができるみたいです。
私はHomeなので代替案としてngrokを使いました。
以下のようにしてセットアップします。
$ curl -s https://ngrok-agent.s3.amazonaws.com/ngrok.asc \
| sudo tee /etc/apt/trusted.gpg.d/ngrok.asc >/dev/null \
&& echo "deb https://ngrok-agent.s3.amazonaws.com buster main" \
| sudo tee /etc/apt/sources.list.d/ngrok.list \
&& sudo apt update \
&& sudo apt install ngrok
TTSモデルのAPI化
まず使いたいモデルはHugging FaceのSpaces用に作られているので、Flaskを使ったAPI化を行います。
moe-ttsにはrequirements.txtがあるので、cat requirements.txt | xargs poetry add
でライブラリをインストールします。
その際、openccはエラーを吐いたので除きました。中国語を日本語に変換するライブラリで、今回は不要です。
moe-ttsのapp.pyを以下のように改変します。
Gradioでの動作部分やVoice Conversion,Soft Voice Conversionの為の関数が多いので、かなり削れます。
また、今回はゆずソフト RIDDLE JOKERの在原七海のボイスを指定したいので、Gradioで複数選択できる部分を編集して直接配列の要素番号を指定します。
import argparse
import os
import re
import warnings
import soundfile as sf
import torch
from torch import LongTensor, no_grad
import commons
import utils
from models import SynthesizerTrn
from text import text_to_sequence
warnings.filterwarnings('ignore')
limitation = os.getenv("SYSTEM") == "spaces" # limit text and audio length in huggingface spaces
def get_text(text, hps, is_symbol):
text_norm = text_to_sequence(text, hps.symbols, [] if is_symbol else hps.data.text_cleaners)
if hps.data.add_blank:
text_norm = commons.intersperse(text_norm, 0)
text_norm = LongTensor(text_norm)
return text_norm
def create_tts_fn(model, hps, speaker_ids):
def tts_fn(text, speaker, speed, is_symbol):
if limitation:
text_len = len(re.sub("\[([A-Z]{2})\]", "", text))
max_len = 150
if is_symbol:
max_len *= 3
if text_len > max_len:
return "Error: Text is too long", None
speaker_id = speaker_ids[speaker]
stn_tst = get_text(text, hps, is_symbol)
with no_grad():
x_tst = stn_tst.unsqueeze(0).to(device)
x_tst_lengths = LongTensor([stn_tst.size(0)]).to(device)
sid = LongTensor([speaker_id]).to(device)
audio = model.infer(x_tst, x_tst_lengths, sid=sid, noise_scale=.667, noise_scale_w=0.8,
length_scale=1.0 / speed)[0][0, 0].data.cpu().float().numpy()
del stn_tst, x_tst, x_tst_lengths, sid
return "Success", (hps.data.sampling_rate, audio)
return tts_fn
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--device', type=str, default='cpu')
parser.add_argument('--text', type=str, required=True, help='Input text for TTS')
parser.add_argument('--speed', type=float, default=1.0, help='Speed for TTS')
args = parser.parse_args()
device = torch.device(args.device)
# 七海だけ読むように変更
config_path = "saved_model/0/config.json"
model_path = "saved_model/0/model.pth"
cover_path = "saved_model/0/cover.jpg"
hps = utils.get_hparams_from_file(config_path)
model = SynthesizerTrn(
len(hps.symbols),
hps.data.filter_length // 2 + 1,
hps.train.segment_size // hps.data.hop_length,
n_speakers=hps.data.n_speakers,
**hps.model)
utils.load_checkpoint(model_path, model, None)
model.eval().to(device)
# 七海を指定
speakers = ["\u5728\u539f\u4e03\u6d77"]
speaker_ids = [6]
tts_fn = create_tts_fn(model, hps, speaker_ids)
output_message, generated_audio = tts_fn(args.text, 0 , args.speed, False)
if output_message == "Success":
sampling_rate, audio_data = generated_audio
sf.write("output.wav", audio_data, sampling_rate)
print("Audio Generated successfully and saved to 'output.wav'")
else:
print(output_message)
Flask APIを立ち上げるためのプログラムを書きます。
# app.py
import shlex
import subprocess
from flask import Flask, jsonify, request, send_file
app = Flask(__name__)
@app.route('/run', methods=['POST'])
def run_script():
# リクエストからパラメータを取得します
text = request.json.get('text', '')
device = request.json.get('device', 'cpu')
speed = request.json.get('speed', 1.0)
# スクリプトコマンドを構築します
command = f"python main.py --text {shlex.quote(text)} --device {shlex.quote(device)} --speed {shlex.quote(str(speed))}"
try:
result = subprocess.run(
shlex.split(command),
capture_output=True,
text=True,
check=True
)
return send_file('./output.wav', as_attachment=True)
except subprocess.CalledProcessError as e:
print(e.stderr)
return jsonify({'error': e.stderr, 'status': 'failure'}), 500
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
サーバーを立ち上げます
$ poetry install
$ poetry run python app.py
* Serving Flask app 'app'
* Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on all addresses (0.0.0.0)
* Running on http://127.0.0.1:5000
* Running on http://172.18.172.133:5000
ngrokでlocalhost:5000を公開されたURLからアクセスできるようにします。
$ ngrok config add-authtoken xxxxxxxxx
$ ngrok http http://localhost:5000
Forwarding https://xxxxxxxx.ngrok-free.app -> http://localhost:5000
コマンドを実行してみて動作すればOKです
$ curl -X POST -H "Content-Type: application/json" -d '{"text": "おはよう"}' https://xxxx.ngrok-free.app/run --output output.wav
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 38980 100 38956 100 24 9978 6 0:00:04 0:00:03 0:00:01 9984
ここまでできたら完了!
Raspberry Pi 5 でTTSモデルを動かす
ちなみに、Raspberry Pi 5でmoe-ttsが動くかどうか試してみましたが、OOMで動きませんでした。
仮に動いたとしても、変換には結構な時間を要するのではないかなとは思います。
動作
実際に動作させてみた動画です
1つ目は待ち時間をカットしていますが、録音終了から出力まで約10秒強掛かっています。
2つ目と3つ目はカットなしです。結構長いですね。
改善点
ここまで見ていただきありがとうございました。
今後も少しずつ改善して修正できたら更新していこうと思います。
アドバイス等ありましたらコメントいただけると助かります。
改善点としては、
- 応答までに10秒ほどかかるのでもう少し速くしたい
- 画像系はLCM-LoraとかあるのでTTSも改変可能?調べてみる
- 実行のさせ方とかもそんな速くないと思う。このあたり良く分かってない
- USB接続のスピーカーを買って差し替える
- 流石に今の音声出力はゴテゴテしてデカい
- たまに発音おかしい
- 直すまでにはめちゃくちゃ勉強が必要そう
- ボタンを押してる間録音→話したら変換にしたい
- 物理ボタンを買ってそのように動作するよう組みなおす
参考ページ