Raspberry pi5とVoicevoxを使った遊び

2024/11/12に公開

はじめに

Raspberrypi, Transcribe, VoiceVox, ChatGPTを使って遊んでみたのでまとめました。
Raspberry piに接続したマイクに音声入力して音声合成で返答するアプリもどきです。
各要素を使ってみたかっただけなので当該コードを実行すると「10秒ほど音声入力を録音した後に文字起こし、返答作成、VoiceVoxの音声読み上げ」の流れを1回行います。

概要

Raspberrypiにマイク、スピーカーを接続。
マイクから入力された音声を録音し、AWS Transcribeで録音した音声をテキストに変換、
Transcribeから得られたテキストをもとにChatGPTで回答を作成、
その回答をVOICEVOXで音声読み上げを行います。

使用するもの

  • Raspberry pi
  • Docker
  • ChatGPT(ChatAPI)
  • AWS(Transcribe, S3)
  • Python(3.11.2)
  • VOICEVOX

環境構築

Raspberry pi

Raspberry piはRaspberry pi5(8GB)を使用。
公式からImagerを持ってきてセットアップします。

Python

Raspberry pi上にPython環境を構築します
こちらの記事を参考にLinux(pyenv, venv)で環境構築。

Docker

後述のVoiceVoxのためRaspberry pi上にDocker環境を構築します
こちらの記事を参考にDockerを構築

VoiceVox

Docker上にVoiceVoxエンジンのコンテナを立ち上げ、APIを通して音声合成ファイルを取得します。
Githubのコメントにもありますが実態はHTTPサーバーなので、リクエストを送信すればテキスト音声合成してくれます。
VoiceVoxはDockerImageが公開されていたため使用させていただきました。
本来はCOEIROINKを使用する予定でしたが環境構築で心が折れたためDockerImageでお試しです。
Dockerコマンドにてイメージをpull, 取得完了後にコンテナを指定のポートで立ち上げます。
バックグラウンドでコンテナを立ち上げたい場合はrunコマンドに-dオプションが必要です。

docker pull voicevox/voicevox_engine:cpu-ubuntu20.04-latest
docker run --rm -p '127.0.0.1:50021:50021' voicevox/voicevox_engine:cpu-ubuntu20.04-latest

コンテナ立ち上げ後に下記コマンドにてquery.json, audio.wavが正常に生成されたら問題ないです。

echo -n "こんにちは、音声合成の世界へようこそ" >text.txt

curl -s \
    -X POST \
    "127.0.0.1:50021/audio_query?speaker=1"\
    --get --data-urlencode text@text.txt \
    > query.json

curl -s \
    -H "Content-Type: application/json" \
    -X POST \
    -d @query.json \
    "127.0.0.1:50021/synthesis?speaker=1" \
    > audio.wav

前述のコマンドの場合、query.jsonは下記jsonが取得できます。

query.json
{
    "accent_phrases": [
        {
            "moras": [
                {
                    "text": "コ",
                    "consonant": "k",
                    "consonant_length": 0.10002633929252625,
                    "vowel": "o",
                    "vowel_length": 0.15740254521369934,
                    "pitch": 5.749961853027344
                },
                {
                    "text": "ン",
                    "consonant": null,
                    "consonant_length": null,
                    "vowel": "N",
                    "vowel_length": 0.08265873789787292,
                    "pitch": 5.891221046447754
                },
                {
                    "text": "ニ",
                    "consonant": "n",
                    "consonant_length": 0.036570820957422256,
                    "vowel": "i",
                    "vowel_length": 0.1175866425037384,
                    "pitch": 5.969864845275879
                },
                {
                    "text": "チ",
                    "consonant": "ch",
                    "consonant_length": 0.09005840867757797,
                    "vowel": "i",
                    "vowel_length": 0.08666137605905533,
                    "pitch": 5.958891868591309
                },
                {
                    "text": "ワ",
                    "consonant": "w",
                    "consonant_length": 0.07833229750394821,
                    "vowel": "a",
                    "vowel_length": 0.21250128746032715,
                    "pitch": 5.949409484863281
                }
            ],
            "accent": 5,
            "pause_mora": {
                "text": "、",
                "consonant": null,
                "consonant_length": null,
                "vowel": "pau",
                "vowel_length": 0.47233399748802185,
                "pitch": 0.0
            },
            "is_interrogative": false
        },
        {
            "moras": [
                {
                    "text": "オ",
                    "consonant": null,
                    "consonant_length": null,
                    "vowel": "o",
                    "vowel_length": 0.22004219889640808,
                    "pitch": 5.687090873718262
                },
                {
                    "text": "ン",
                    "consonant": null,
                    "consonant_length": null,
                    "vowel": "N",
                    "vowel_length": 0.09161104261875153,
                    "pitch": 5.934728622436523
                },
                {
                    "text": "セ",
                    "consonant": "s",
                    "consonant_length": 0.0892481803894043,
                    "vowel": "e",
                    "vowel_length": 0.14142127335071564,
                    "pitch": 6.12185001373291
                },
                {
                    "text": "エ",
                    "consonant": null,
                    "consonant_length": null,
                    "vowel": "e",
                    "vowel_length": 0.10636936873197556,
                    "pitch": 6.157895088195801
                },
                {
                    "text": "ゴ",
                    "consonant": "g",
                    "consonant_length": 0.07600922882556915,
                    "vowel": "o",
                    "vowel_length": 0.09598275274038315,
                    "pitch": 6.188932418823242
                },
                {
                    "text": "オ",
                    "consonant": null,
                    "consonant_length": null,
                    "vowel": "o",
                    "vowel_length": 0.107912078499794,
                    "pitch": 6.235201358795166
                },
                {
                    "text": "セ",
                    "consonant": "s",
                    "consonant_length": 0.0959184393286705,
                    "vowel": "e",
                    "vowel_length": 0.10286371409893036,
                    "pitch": 6.153213024139404
                },
                {
                    "text": "エ",
                    "consonant": null,
                    "consonant_length": null,
                    "vowel": "e",
                    "vowel_length": 0.089926578104496,
                    "pitch": 6.025710582733154
                },
                {
                    "text": "ノ",
                    "consonant": "n",
                    "consonant_length": 0.05660200119018555,
                    "vowel": "o",
                    "vowel_length": 0.09676022082567215,
                    "pitch": 5.711842060089111
                }
            ],
            "accent": 5,
            "pause_mora": null,
            "is_interrogative": false
        },
        {
            "moras": [
                {
                    "text": "セ",
                    "consonant": "s",
                    "consonant_length": 0.07805489003658295,
                    "vowel": "e",
                    "vowel_length": 0.09617520123720169,
                    "pitch": 5.774398326873779
                },
                {
                    "text": "カ",
                    "consonant": "k",
                    "consonant_length": 0.06712040305137634,
                    "vowel": "a",
                    "vowel_length": 0.14882932603359222,
                    "pitch": 6.063966751098633
                },
                {
                    "text": "イ",
                    "consonant": null,
                    "consonant_length": null,
                    "vowel": "i",
                    "vowel_length": 0.11061099171638489,
                    "pitch": 6.040699005126953
                },
                {
                    "text": "エ",
                    "consonant": null,
                    "consonant_length": null,
                    "vowel": "e",
                    "vowel_length": 0.13046695291996002,
                    "pitch": 5.806028366088867
                }
            ],
            "accent": 1,
            "pause_mora": null,
            "is_interrogative": false
        },
        {
            "moras": [
                {
                    "text": "ヨ",
                    "consonant": "y",
                    "consonant_length": 0.07194740325212479,
                    "vowel": "o",
                    "vowel_length": 0.08622607588768005,
                    "pitch": 5.694095611572266
                },
                {
                    "text": "オ",
                    "consonant": null,
                    "consonant_length": null,
                    "vowel": "o",
                    "vowel_length": 0.10635453462600708,
                    "pitch": 5.787221908569336
                },
                {
                    "text": "コ",
                    "consonant": "k",
                    "consonant_length": 0.07077332586050034,
                    "vowel": "o",
                    "vowel_length": 0.09248622506856918,
                    "pitch": 5.793358325958252
                },
                {
                    "text": "ソ",
                    "consonant": "s",
                    "consonant_length": 0.08705664426088333,
                    "vowel": "o",
                    "vowel_length": 0.22382576763629913,
                    "pitch": 5.643765926361084
                }
            ],
            "accent": 1,
            "pause_mora": null,
            "is_interrogative": false
        }
    ],
    "speedScale": 1.0,
    "pitchScale": 0.0,
    "intonationScale": 1.0,
    "volumeScale": 1.0,
    "prePhonemeLength": 0.1,
    "postPhonemeLength": 0.1,
    "pauseLength": null,
    "pauseLengthScale": 1.0,
    "outputSamplingRate": 24000,
    "outputStereo": false,
    "kana": "コンニチワ'、オンセエゴ'オセエノ/セ'カイエ/ヨ'オコソ"
}

Amazon Transcribe

Transcribeはリアルタイム、事前用意した音声から文字起こしをしてくれるサービスです。
主に下記2つの機能があります。詳細はこちら

  • バッチ:S3にアップロードされたファイルを文字起こし
  • ストリーミング:メディアストリームをリアルタイムで書き起こし

今回は簡単な実装にするためにバッチを採用します。
簡単にバッチの流れを説明するとTranscribeのジョブという単位でS3にアップロードしたメディアファイルを文字起こしします。文字起こしが正常に完了するとジョブ作成時に指定する任意のS3バケットに文字起こし結果のJsonが格納されます。
下記は実際に文字起こし結果のJsonを取得した内容です。

文字起こし結果のJsonサンプル
{
    "jobName": "Example-job-2024-10-22-20-58-38",
    "accountId": "accountId",
    "status": "COMPLETED",
    "results": {
        "transcripts": [
            {
                "transcript": "こんばんは。今日も仕事で疲れました。仕事で失敗したので、少しへこんでおります。"
            }
        ],
        "items": [
            {
                "id": 0,
                "type": "pronunciation",
                "alternatives": [
                    {
                        "confidence": "0.994",
                        "content": "こんばんは"
                    }
                ],
                "start_time": "1.37",
                "end_time": "2.13"
            },
            {
                "id": 1,
                "type": "punctuation",
                "alternatives": [
                    {
                        "confidence": "0.0",
                        "content": "。"
                    }
                ]
            },
            {
                "id": 2,
                "type": "pronunciation",
                "alternatives": [
                    {
                        "confidence": "0.996",
                        "content": "今日"
                    }
                ],
                "start_time": "2.849",
                "end_time": "3.099"
            },
            {
                "id": 3,
                "type": "pronunciation",
                "alternatives": [
                    {
                        "confidence": "0.998",
                        "content": "も"
                    }
                ],
                "start_time": "3.109",
                "end_time": "3.38"
            },
            {
                "id": 4,
                "type": "pronunciation",
                "alternatives": [
                    {
                        "confidence": "0.999",
                        "content": "仕事"
                    }
                ],
                "start_time": "3.39",
                "end_time": "3.779"
            },
            {
                "id": 5,
                "type": "pronunciation",
                "alternatives": [
                    {
                        "confidence": "0.998",
                        "content": "で"
                    }
                ],
                "start_time": "3.789",
                "end_time": "3.92"
            },
            {
                "id": 6,
                "type": "pronunciation",
                "alternatives": [
                    {
                        "confidence": "0.998",
                        "content": "疲れ"
                    }
                ],
                "start_time": "3.93",
                "end_time": "4.26"
            },
            {
                "id": 7,
                "type": "pronunciation",
                "alternatives": [
                    {
                        "confidence": "0.999",
                        "content": "まし"
                    }
                ],
                "start_time": "4.269",
                "end_time": "4.539"
            },
            {
                "id": 8,
                "type": "pronunciation",
                "alternatives": [
                    {
                        "confidence": "0.999",
                        "content": "た"
                    }
                ],
                "start_time": "4.55",
                "end_time": "4.78"
            },
            {
                "id": 9,
                "type": "punctuation",
                "alternatives": [
                    {
                        "confidence": "0.0",
                        "content": "。"
                    }
                ]
            },
            {
                "id": 10,
                "type": "pronunciation",
                "alternatives": [
                    {
                        "confidence": "0.998",
                        "content": "仕事"
                    }
                ],
                "start_time": "6.44",
                "end_time": "6.86"
            },
            {
                "id": 11,
                "type": "pronunciation",
                "alternatives": [
                    {
                        "confidence": "0.998",
                        "content": "で"
                    }
                ],
                "start_time": "6.869",
                "end_time": "6.98"
            },
            {
                "id": 12,
                "type": "pronunciation",
                "alternatives": [
                    {
                        "confidence": "0.999",
                        "content": "失敗"
                    }
                ],
                "start_time": "6.989",
                "end_time": "7.38"
            },
            {
                "id": 13,
                "type": "pronunciation",
                "alternatives": [
                    {
                        "confidence": "0.999",
                        "content": "し"
                    }
                ],
                "start_time": "7.389",
                "end_time": "7.46"
            },
            {
                "id": 14,
                "type": "pronunciation",
                "alternatives": [
                    {
                        "confidence": "0.999",
                        "content": "た"
                    }
                ],
                "start_time": "7.469",
                "end_time": "7.579"
            },
            {
                "id": 15,
                "type": "pronunciation",
                "alternatives": [
                    {
                        "confidence": "0.999",
                        "content": "の"
                    }
                ],
                "start_time": "7.59",
                "end_time": "7.73"
            },
            {
                "id": 16,
                "type": "pronunciation",
                "alternatives": [
                    {
                        "confidence": "0.999",
                        "content": "で"
                    }
                ],
                "start_time": "7.739",
                "end_time": "7.969"
            },
            {
                "id": 17,
                "type": "punctuation",
                "alternatives": [
                    {
                        "confidence": "0.0",
                        "content": "、"
                    }
                ]
            },
            {
                "id": 18,
                "type": "pronunciation",
                "alternatives": [
                    {
                        "confidence": "0.998",
                        "content": "少し"
                    }
                ],
                "start_time": "8.159",
                "end_time": "8.72"
            },
            {
                "id": 19,
                "type": "pronunciation",
                "alternatives": [
                    {
                        "confidence": "0.989",
                        "content": "へこん"
                    }
                ],
                "start_time": "8.729",
                "end_time": "9.05"
            },
            {
                "id": 20,
                "type": "pronunciation",
                "alternatives": [
                    {
                        "confidence": "0.999",
                        "content": "で"
                    }
                ],
                "start_time": "9.06",
                "end_time": "9.26"
            },
            {
                "id": 21,
                "type": "pronunciation",
                "alternatives": [
                    {
                        "confidence": "0.997",
                        "content": "おり"
                    }
                ],
                "start_time": "9.27",
                "end_time": "9.46"
            },
            {
                "id": 22,
                "type": "pronunciation",
                "alternatives": [
                    {
                        "confidence": "0.999",
                        "content": "ます"
                    }
                ],
                "start_time": "9.47",
                "end_time": "9.899"
            },
            {
                "id": 23,
                "type": "punctuation",
                "alternatives": [
                    {
                        "confidence": "0.0",
                        "content": "。"
                    }
                ]
            }
        ],
        "audio_segments": [
            {
                "id": 0,
                "transcript": "こんばんは。今日も仕事で疲れました。仕事で失敗したので、少しへこんでおります。",
                "start_time": "0.68",
                "end_time": "10.01",
                "items": [
                    0,
                    1,
                    2,
                    3,
                    4,
                    5,
                    6,
                    7,
                    8,
                    9,
                    10,
                    11,
                    12,
                    13,
                    14,
                    15,
                    16,
                    17,
                    18,
                    19,
                    20,
                    21,
                    22,
                    23
                ]
            }
        ]
    }
}

Amazon S3

Amazon Transcribeの実行完了後に出力される文字起こし結果を保存するバケットを用意します。
また、ユーザーから録音した音声のアップロード先としてS3のバケットを用意します。

ChatGPT(ChatCompletion)

VoiceVoxが読み上げる返答をChatGPTに作成してもらいます。
ChatGPTのAPIの詳細はこちらに記載あります
また、今回ChatGPTのAPI使用にあたって課金が必要だったため課金を行ってます。
課金したくない場合は別のサービス等を使用する必要があります。

クライアント実装

実際のコードはこちら
https://github.com/YujiYoshizumi/voiceroidpractice/
S3, Transcribeを使用するためにBoto3を使用します。
また、Boto3使用のためにAWSのCredential設定が必要なので事前に行います。
今回はaws cliから行いました。こちらの情報を元にCredentialを作成してます。

次にクライアント側のコードをいくつかピックアップします。
前提として環境変数を取得する実装となっています。
環境変数についてはこちらの記事が参考になります。

main.py
from dotenv import load_dotenv
import os

# .envファイルを読み込む
load_dotenv()

# 環境変数の取得
OPEN_AI_API_KEY = os.getenv('OPEN_AI_API_KEY')
AWS_REGION = os.getenv('AWS_REGION')
S3_BUCKET_NAME = os.getenv('S3_BUCKET_NAME')
USER_VOICE_OUTPUT_FILENAME = os.getenv('USER_VOICE_OUTPUT_FILENAME')
VOICEROID_OUTPUT_FILENAME = os.getenv('VOICEROID_OUTPUT_FILENAME')
VOICEROID_SYNTHESIS_URI = os.getenv('VOICEROID_SYNTHESIS_URI')
VOICEROID_AUDIO_QUERY_URI = os.getenv('VOICEROID_AUDIO_QUERY_URI')

下記はAmazon Transcribeのジョブ作成~ジョブ完了後のJson格納Uri取得です。
Transcribeのジョブ名は一意になる必要があるため現在時刻を使用しています。
Transcribeでは文字起こしをする際に入力される音声の言語が指定できます。
今回は日本語のためja-JPを指定してます。
サポートされていない言語や言語によっては使用できない機能があるため確認が必要です。
こちらから確認できます
start_transcription_jobでジョブを作成後に、Completeとなるまで待ちます。
ジョブがCompleteになった後、文字起こし結果のJsonの出力先URIを取得してます。

main.py
def transcribe_file():
    # S3に保存されているユーザーの音声のwavのURI
    file_uri = f's3://{S3_BUCKET_NAME}/{USER_VOICE_OUTPUT_FILENAME}'

    # transcribeのjob名用の日付
    current_time_str = datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
    job_name = f'Example-job-{current_time_str}'

    transcribe_client = boto3.client('transcribe', region_name = AWS_REGION)
    transcribe_client.start_transcription_job(
        TranscriptionJobName = job_name,
        Media = {
            # ユーザーの録音音声のURI(S3のバケット)
            'MediaFileUri': file_uri
        # Transcribeに渡すファイルの拡張子
        MediaFormat = 'wav',
        # 音声の言語
        LanguageCode = 'ja-JP',
        OutputBucketName = S3_BUCKET_NAME
    )

    max_tries = 60
    while max_tries > 0:
        max_tries -= 1
        job = transcribe_client.get_transcription_job(TranscriptionJobName = job_name)
        job_status = job['TranscriptionJob']['TranscriptionJobStatus']
        if job_status in ['COMPLETED', 'FAILED']:
            print(f"Job {job_name} is {job_status}.")
            if job_status == 'COMPLETED':
                file_uri = job['TranscriptionJob']['Transcript']['TranscriptFileUri']
                print(
                    f"Download the transcript from\n"
                    f"\t{file_uri}.")
                break
        else:
            print(f"Waiting for {job_name}. Current status is {job_status}.")
        time.sleep(10)
    return file_uri

下記はTranscribeの出力結果のJsonを取得するコードです
先ほどのtranscribe_file関数から取得したURIを使用してS3からファイルを取得してjsonファイルとして読み込みます。
使用したいのは文字起こし結果のaudio_segments配下のtranscript部分のみなのでそこだけ取得します。

main.py
def getVoiceText(transcribe_file_uri):
    split_result = transcribe_file_uri.split("/")
    content = get_file_from_s3(split_result[-1])
    json_data = json.loads(content)
    voice_text = json_data['results']['audio_segments'][0]['transcript']
    return voice_text

次にVoiceVoxへのリクエストを行うコードについてです。
HTTP Requestを各URLにて行ってます。
VoiceVoxのAPI仕様を確認するとPostじゃなくても良いAPIもあるので適宜メソッドは変更してください。
SynthesisAPIのパラメータであるSpeakerのIDについては使用したいSpeakerがいる場合は別途speakersAPIを使用して確認する必要があります。
SpeakersAPIの各Speaker内のstylesのidを指定すると使用したいSpeakerになります。
現在はspeakerを1に指定しているため「ずんだもん」になってます。

main.py
def requestVoiceroidQuery(voice_text):
    params = {
        'speaker': 1,
        'text': voice_text
    }

    response = requests.post(VOICEROID_AUDIO_QUERY_URI, params=params)

    if response.status_code == 200:
        print("Success:", response.json())
    else:
        print("Error:", response.status_code)
    return response.json()

def requestAndGetVoiceroidText(voice_text_json):
    headers = {
        'Content-Type': 'application/json'
    }

    params = {
        'speaker': 1
    }

    response = requests.post(VOICEROID_SYNTHESIS_URI, headers=headers, params=params, json=voice_text_json)

    if response.status_code == 200:
        print("Success")
        with open(VOICEROID_OUTPUT_FILENAME, 'wb') as f:
            f.write(response.content)
    else:
        print("Error:", response.status_code)

最後に

遊びとは言え実際に動かすと入力から返答が出力されるまでかなりかかるため改善したい箇所は多いです。具体的にはAWS内にまとめる、TranscribeのバッチではなくStreamを使用する、Transcribeではなく他手段で文字起こしするなど。
あとはWebサイトとして構築してもっとグラフィカルにしたりですね。
AWS勉強中のため機会があれば改善した構成でWebサイト構築したいと思ってますが機会があれば...。

参考

Discussion