LINE でずんだもんを喋らせたい

# はじめに
最近、YouTube を見ているとずんだもんに喋らせている動画をよくみかけます。
自分でもずんだもん使ってみたいなと思い、LINE に降臨させてみることにしました。
入力した文字列を音声化する bot を作ってみます。

# Messaging API での音声メッセージの扱い

LINE の Messaging API のリファレンスを確認すると、音声メッセージを扱うには、音声ファイルの URL と長さを LINE 側に渡してあげる必要がありそうです。
具体的には、下記の形式の JSON を LINE のエンドポイントに送信してあげれば良さそうです。
```
{
  "type": "audio",
  "originalContentUrl": "https://example.com/original.m4a",
  "duration": 60000
}
```
参考：Messaging APIリファレンス - 音声メッセージ

https://developers.line.biz/ja/reference/messaging-api/#audio-message


そのため、VOICEVOX で生成した音声をストレージに配置し、その URL と長さを返却する API を作ることにします。


# API の構成

今回は AWS の API Gateway、Lambda、S3 を使って API を作ってみます。
イメージ図としては以下の感じです。
Bot サーバーから音声にしたいテキストを含んだリクエストを API に対して行い、取得した音声データの URL を LINE に送信する構成となります。
Bot サーバーについては[以前に書いた記事](https://qiita.com/wanwan_fox/items/8c535ad2532a2030e341)も参考にしてください。

![aws-zu.drawio (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3115963/3a6900e3-4a00-6265-d165-8024c9b44176.png)

# 実装
リソースの作成には AWS CDK を使用します。
テンプレートとして残しておくと、ポチポチしなくてもリソースが作れるし、管理が便利です。
Lambda 関数のデプロイにはコンテナを使いたいと思います。

フォルダ構成としてはざっくりと以下の感じです。CDK のフォルダ構成の lib フォルダ以下を載せておきます。
```
lib
├── cdk-zundamon-stack.ts
└── lambda-template
    ├── app
    │   ├── lambda_function.py
    │   └── requirements.txt
    └── Dockerfile
```
この辺の手順でプロジェクト作成すると、いい感じにフォルダが作成されるので、lib 以下を置き換えてください。ts ファイルの名前はプロジェクト名に合わせて適宜変えてください。

https://docs.aws.amazon.com/ja_jp/cdk/v2/guide/work-with-cdk-typescript.html


## Dockerfile
Lambda 上で VOICEVOX を動作させるために、今回はコンテナを用いることにします。

Lambda にデプロイするベースのコンテナイメージは [python のイメージ](https://hub.docker.com/_/python)としました。
こちらに Lambda Runtime API とのやり取りをしてくれるランタイムインターフェイスクライアント: awslambdaric をインストールして、Lambda 上で稼働させます。
手順としてはこちらです。

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/python-image.html#python-image-clients


Lambda 用に [Python のベースイメージ](https://gallery.ecr.aws/lambda/python/)も用意されているのですが、こちらを使うと VOICEVOX に必要なモジュールが一部足りていないらしく起動しなかったため、今回は採用していません。

VOICEVOX のインストールは下記を参考にしました。

https://github.com/VOICEVOX/voicevox_core/tree/main/example/python

```
FROM python:3

RUN apt-get update
RUN pip install awslambdaric boto3 wave --upgrade pip

RUN mkdir /function
WORKDIR /function

RUN pip install https://github.com/VOICEVOX/voicevox_core/releases/download/0.15.0/voicevox_core-0.15.0+cpu-cp38-abi3-linux_x86_64.whl
RUN binary=download-linux-x64 && \
    curl -sSfL https://github.com/VOICEVOX/voicevox_core/releases/latest/download/${binary} -o download  && \
    chmod +x download  && \
    ./download -o ./

COPY ./lambda_function.py /function/

ENTRYPOINT [ "/usr/local/bin/python", "-m", "awslambdaric" ]

CMD [ "lambda_function.handler" ]  
```



## lambda_function.py
処理としては大まかに以下となります。
1. リクエストのクエリパラメータの text として送信されてきた文字列を取得し、音声データに変換する
2. 音声ファイルを S3 バケットに保存する
3. 音声ファイルの署名付き URL を発行する
4. 音声ファイルの長さを取得
5. 音声ファイルの長さと URL をレスポンスする

S3 バケットに保存した音声ファイルには署名付き URL を発行することにしました。知っている人だけがアクセスできる期限付きの URL となります。S3 バケットを不特定多数の人に公開したくないので、このような構成にしました。

https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/using-presigned-url.html

音声ファイルの長さを取得するのには wave ライブラリを使用しました。
一旦、ローカルにファイル保存をする以外にファイルの長さを取得する方法がわからなかったので、ローカルに保存しつつ長さを取得しています。

話者に関しては、SPEAKER_ID で設定しています。METAS オブジェクトに話者の情報は格納されているようです。
とりあえずは 1:ずんだもん（あまあま）にしています。

```
import json
import boto3
import voicevox_core
from voicevox_core import AccelerationMode, AudioQuery, VoicevoxCore, METAS
from pathlib import Path
import wave
import os
import datetime

s3 = boto3.client('s3')
bucket = os.environ['BUCKET_NAME']

# 話者は1:ずんだもん（あまあま）に設定。METAS から話者のリストが確認可能。
SPEAKER_ID = 1
print(METAS)
                
open_jtalk_dict_dir = './open_jtalk_dic_utf_8-1.11'
acceleration_mode = AccelerationMode.AUTO
core = VoicevoxCore(
        acceleration_mode=acceleration_mode, open_jtalk_dict_dir=open_jtalk_dict_dir
    )
core.load_model(SPEAKER_ID)

def handler(event, context):

    # 1. リクエストのクエリパラメータの text として送信されてきた文字列を取得し、音声データに変換する
    text = event['queryStringParameters']['text']
    audio_query = core.audio_query(text, SPEAKER_ID)
    wav = core.synthesis(audio_query, SPEAKER_ID)

    # 2. 音声ファイルを S3 バケットに保存する
    key = datetime.datetime.now().strftime('%Y%M%d%S') + '.wav'
    s3.put_object(
        Bucket= bucket,
        Body = wav,
        Key = key
    )    

    # 3. 音声ファイルの署名付き URL を発行する
    presigned_url = s3.generate_presigned_url(
        ClientMethod = 'get_object',
        Params = {'Bucket' : bucket, 'Key' : key},
        ExpiresIn = 3600,
        HttpMethod = 'GET')

    # 4. 音声ファイルの長さを取得
    path = '/tmp/' + key
    wr = open(path, 'wb')
    wr.write(wav)
    wr.close()
    wf = wave.open(path, mode='rb')
    audio_length = int((wf.getnframes() / wf.getframerate()) * 1000)
    os.remove(path)

    # 5. 音声ファイルの長さと URL をレスポンスする
    response = {
            'Audio-Length': audio_length,
            'url': presigned_url
            }

    return {
        'statusCode': 200,
        'body': json.dumps(response)
    }
```

## cdk-zundamon-stack.ts

CDK のテンプレートとなります。
リソースの名前が被らないように末尾に uuid を付与しています。

Lambda のメモリサイズは 2048 MB を割り当てています。一応動くことは確認しましたが、必要に応じて変更してください。Lambda 上の処理で時間がかかると API Gateway 側のタイムアウトに引っかかる可能性もあります。（デフォルト29秒）
試したところ、初回実行は Lambda の Init フェイズに時間がかかるため、タイムアウトが発生しました。とはいえ、API Gateway 側のタイムアウト値が29秒が最大のようなので、Lambda のメモリサイズをさらに大きくするなり、provisioned concurrency を有効にするなりで対応する必要がありそうです。初回実行が済めばその後の実行は時間内に成功しました。
細かい実装は API リファレンスを参照してください。

https://docs.aws.amazon.com/cdk/api/v2/docs/aws-construct-library.html
```
import { Construct } from 'constructs';
import * as lambda  from 'aws-cdk-lib/aws-lambda';
import * as apigateway  from 'aws-cdk-lib/aws-apigateway';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as iam from 'aws-cdk-lib/aws-iam';
import { Stack, StackProps, Duration, RemovalPolicy, CfnOutput } from 'aws-cdk-lib';
import {v4 as uuidv4} from 'uuid';

export class CdkZundamonStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);
        
        const systemName:string = uuidv4()

        # S3
        const bucket = new s3.Bucket(this, 'zundamon-bucket-' + systemName , {
          bucketName:'zundamon-bucket-' + systemName,
          removalPolicy: RemovalPolicy.DESTROY,
          autoDeleteObjects: true,
          lifecycleRules: [
            {
              id: 'zundamon-lifecycle-' + systemName,
              expiration: Duration.days(1),
            }
          ]
        });

        # Lambda
        const zundamon_lambda_policy = new iam.ManagedPolicy(this, 'zundamon-policy-' + systemName, {
          managedPolicyName: 'zundamon-policy-' + systemName,
          statements: [
            new iam.PolicyStatement({
              effect: iam.Effect.ALLOW,
              actions: ['logs:*'],
              resources: ['arn:aws:logs:*:*:*'],
            }),
            new iam.PolicyStatement({
              effect: iam.Effect.ALLOW,
              actions: ['s3:PutObject','s3:GetObject'],
              resources: [`${bucket.bucketArn}/*`],
            })
          ],
        });
                
        const lambdaRole = new iam.Role(this, 'zundamon-role-' + systemName, {
          assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
        });
        lambdaRole.addManagedPolicy(zundamon_lambda_policy);

        const Function = new lambda.DockerImageFunction(this, 'zundamon-function-' + systemName, {
        functionName: 'zundamon-function-' + systemName,
        code: lambda.DockerImageCode.fromImageAsset(`${__dirname}/lambda-template`),
        timeout: Duration.minutes(5),
        memorySize: 2048,
        role: lambdaRole,
        environment: {
                BUCKET_NAME: bucket.bucketName
        }
        });

        # API Gateway
        const api = new apigateway.RestApi(this, "zundamon-api-" + systemName,{
          defaultMethodOptions: { 
            authorizationType: apigateway.AuthorizationType.IAM,
           }
        })
    
        const getMethod = api.root.addResource("zundamon")
        getMethod.addMethod("GET", new apigateway.LambdaIntegration(Function))

        # Output
        new CfnOutput(this, 'API-Gateay-Endpoint', {
          value: api.url
        })
    }
}
```

## デプロイ
ここまで用意できたらリソースをデプロイします。
CDK のプロジェクトディレクトリに移動して、cdk deploy コマンドを実行します。
完了すると、アウトプットとして API Gateway のエンドポイントの URL が出力されます。


## LINE Bot サーバー
API が完成したので LINE Bot サーバー側も実装します。
LINE 側で入力された文字列を API に送信して音声ファイルの情報を受け取り、Messaging API のリファレンスに従ってレスポンスします。

先頭に !zundamon とあった場合に、以降の文字列を音声に変換する実装にしました。

url には作成された API Gateway の URL に /zundamon を加えたものを指定してください。/zundamon に関しては CDK テンプレート内でパスに指定したものなので、変更した場合には url も適宜変更してください。

API Gateway には IAM 認証をかけているため、Sigv4 署名を付与してリクエストを行います。
requests_aws4auth を使っています。

https://pypi.org/project/requests-aws4auth/

Bot サーバーの実装に関しては以前の記事も参考にしてください。

https://qiita.com/wanwan_fox/items/8c535ad2532a2030e341
```
import os, json
from linebot.v3 import (
    WebhookHandler
)
from linebot.v3.exceptions import (
    InvalidSignatureError
)
from linebot.v3.messaging import (
    Configuration,
    ApiClient,
    MessagingApi,
    ReplyMessageRequest,
    TextMessage,
    AudioMessage
)
from linebot.v3.webhooks import (
    MessageEvent,
    TextMessageContent
)
import logging
import requests
from requests_aws4auth import AWS4Auth
import boto3

logger = logging.getLogger()
logger.setLevel(logging.ERROR)

channel_access_token = os.getenv('LINE_CHANNEL_ACCESS_TOKEN')
channel_secret = os.getenv('LINE_CHANNEL_SECRET')

configuration = Configuration(access_token=channel_access_token)
api_client = ApiClient(configuration) 
line_bot_api = MessagingApi(api_client)
handler = WebhookHandler(channel_secret)

def lambda_handler(event, context):
    signature = event["headers"]["x-line-signature"]
    body = event["body"]
    
    @handler.add(MessageEvent, message=TextMessageContent)
    def handle_message(event):
        text = event.message.text
        
        if '!zundamon' in text:
            url = '＜API Gateway の URL＞/zundamon'
            credentials = boto3.Session().get_credentials()
            region = 'ap-northeast-1'
            service = 'execute-api'
            
            auth = AWS4Auth(
                credentials.access_key,
                credentials.secret_key,
                region,
                service,
                session_token=credentials.token,
            )
            
            params ={
                        'text': text.split(sep=None,maxsplit=1)[1]
                    }
                
            res = requests.get(url, params = params,  auth=auth)           
            tmp = json.loads(res.text)
                        
            url = tmp['url']
            duration = int(tmp['Audio-Length'])
                        
            line_bot_api.reply_message_with_http_info(
                ReplyMessageRequest(
                    reply_token=event.reply_token,
                    messages=[AudioMessage(originalContentUrl=url, duration=duration)]
                )
            )

    try:
        handler.handle(body, signature)
    except InvalidSignatureError:
        message = "Invalid signature. Please check your channel access token/channel secret."
        logger.error(message)
        return {'statusCode': 400,'body': message}
    
    return {'statusCode': 200, 'body': "OK"}
```

# 動作確認
完成したら動作確認をしてみます。
LINE で !zundamon コマンドを実行して、音声が返却されれば成功です。
甘々なずんだもんが返事をしてくれます。

![Screenshot_20240325-201936.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3115963/3a03fecd-8ed8-46b8-1f03-25b925de7cf1.png)

# おまけ
現時点で話者として選択できるのは以下となるようです。
METAS オブジェクトを print した情報となります。
```
[Meta(name='四国めたん',
      styles=[Style(name='ノーマル', id=2),
              Style(name='あまあま', id=0),
              Style(name='ツンツン', id=6),
              Style(name='セクシー', id=4),
              Style(name='ささやき', id=36),
              Style(name='ヒソヒソ', id=37)],
      speaker_uuid='7ffcb7ce-00ec-4bdc-82cd-45a8889e43ff',
      version='0.14.4'),
 Meta(name='ずんだもん',
      styles=[Style(name='ノーマル', id=3),
              Style(name='あまあま', id=1),
              Style(name='ツンツン', id=7),
              Style(name='セクシー', id=5),
              Style(name='ささやき', id=22),
              Style(name='ヒソヒソ', id=38)],
      speaker_uuid='388f246b-8c41-4ac1-8e2d-5d79f3ff56d9',
      version='0.14.4'),
 Meta(name='春日部つむぎ',
      styles=[Style(name='ノーマル', id=8)],
      speaker_uuid='35b2c544-660e-401e-b503-0e14c635303a',
      version='0.14.4'),
 Meta(name='雨晴はう',
      styles=[Style(name='ノーマル', id=10)],
      speaker_uuid='3474ee95-c274-47f9-aa1a-8322163d96f1',
      version='0.14.4'),
 Meta(name='波音リツ',
      styles=[Style(name='ノーマル', id=9), Style(name='クイーン', id=65)],
      speaker_uuid='b1a81618-b27b-40d2-b0ea-27a9ad408c4b',
      version='0.14.4'),
 Meta(name='玄野武宏',
      styles=[Style(name='ノーマル', id=11),
              Style(name='喜び', id=39),
              Style(name='ツンギレ', id=40),
              Style(name='悲しみ', id=41)],
      speaker_uuid='c30dc15a-0992-4f8d-8bb8-ad3b314e6a6f',
      version='0.14.4'),
 Meta(name='白上虎太郎',
      styles=[Style(name='ふつう', id=12),
              Style(name='わーい', id=32),
              Style(name='びくびく', id=33),
              Style(name='おこ', id=34),
              Style(name='びえーん', id=35)],
      speaker_uuid='e5020595-5c5d-4e87-b849-270a518d0dcf',
      version='0.14.4'),
 Meta(name='青山龍星',
      styles=[Style(name='ノーマル', id=13)],
      speaker_uuid='4f51116a-d9ee-4516-925d-21f183e2afad',
      version='0.14.4'),
 Meta(name='冥鳴ひまり',
      styles=[Style(name='ノーマル', id=14)],
      speaker_uuid='8eaad775-3119-417e-8cf4-2a10bfd592c8',
      version='0.14.4'),
 Meta(name='九州そら',
      styles=[Style(name='ノーマル', id=16),
              Style(name='あまあま', id=15),
              Style(name='ツンツン', id=18),
              Style(name='セクシー', id=17),
              Style(name='ささやき', id=19)],
      speaker_uuid='481fb609-6446-4870-9f46-90c4dd623403',
      version='0.14.4'),
 Meta(name='もち子さん',
      styles=[Style(name='ノーマル', id=20), Style(name='セクシー／あん子', id=66)],
      speaker_uuid='9f3ee141-26ad-437e-97bd-d22298d02ad2',
      version='0.14.4'),
 Meta(name='剣崎雌雄',
      styles=[Style(name='ノーマル', id=21)],
      speaker_uuid='1a17ca16-7ee5-4ea5-b191-2f02ace24d21',
      version='0.14.4'),
 Meta(name='WhiteCUL',
      styles=[Style(name='ノーマル', id=23),
              Style(name='たのしい', id=24),
              Style(name='かなしい', id=25),
              Style(name='びえーん', id=26)],
      speaker_uuid='67d5d8da-acd7-4207-bb10-b5542d3a663b',
      version='0.14.4'),
 Meta(name='後鬼',
      styles=[Style(name='人間ver.', id=27), Style(name='ぬいぐるみver.', id=28)],
      speaker_uuid='0f56c2f2-644c-49c9-8989-94e11f7129d0',
      version='0.14.4'),
 Meta(name='No.7',
      styles=[Style(name='ノーマル', id=29),
              Style(name='アナウンス', id=30),
              Style(name='読み聞かせ', id=31)],
      speaker_uuid='044830d2-f23b-44d6-ac0d-b5d733caa900',
      version='0.14.4'),
 Meta(name='ちび式じい',
      styles=[Style(name='ノーマル', id=42)],
      speaker_uuid='468b8e94-9da4-4f7a-8715-a22a48844f9e',
      version='0.14.4'),
 Meta(name='櫻歌ミコ',
      styles=[Style(name='ノーマル', id=43),
              Style(name='第二形態', id=44),
              Style(name='ロリ', id=45)],
      speaker_uuid='0693554c-338e-4790-8982-b9c6d476dc69',
      version='0.14.4'),
 Meta(name='小夜/SAYO',
      styles=[Style(name='ノーマル', id=46)],
      speaker_uuid='a8cc6d22-aad0-4ab8-bf1e-2f843924164a',
      version='0.14.4'),
 Meta(name='ナースロボ＿タイプＴ',
      styles=[Style(name='ノーマル', id=47),
              Style(name='楽々', id=48),
              Style(name='恐怖', id=49),
              Style(name='内緒話', id=50)],
      speaker_uuid='882a636f-3bac-431a-966d-c5e6bba9f949',
      version='0.14.4'),
 Meta(name='†聖騎士 紅桜†',
      styles=[Style(name='ノーマル', id=51)],
      speaker_uuid='471e39d2-fb11-4c8c-8d89-4b322d2498e0',
      version='0.14.4'),
 Meta(name='雀松朱司',
      styles=[Style(name='ノーマル', id=52)],
      speaker_uuid='0acebdee-a4a5-4e12-a695-e19609728e30',
      version='0.14.4'),
 Meta(name='麒ヶ島宗麟',
      styles=[Style(name='ノーマル', id=53)],
      speaker_uuid='7d1e7ba7-f957-40e5-a3fc-da49f769ab65',
      version='0.14.4'),
 Meta(name='春歌ナナ',
      styles=[Style(name='ノーマル', id=54)],
      speaker_uuid='ba5d2428-f7e0-4c20-ac41-9dd56e9178b4',
      version='0.14.4'),
 Meta(name='猫使アル',
      styles=[Style(name='ノーマル', id=55),
              Style(name='おちつき', id=56),
              Style(name='うきうき', id=57)],
      speaker_uuid='00a5c10c-d3bd-459f-83fd-43180b521a44',
      version='0.14.4'),
 Meta(name='猫使ビィ',
      styles=[Style(name='ノーマル', id=58),
              Style(name='おちつき', id=59),
              Style(name='人見知り', id=60)],
      speaker_uuid='c20a2254-0349-4470-9fc8-e5c0f8cf3404',
      version='0.14.4'),
 Meta(name='中国うさぎ',
      styles=[Style(name='ノーマル', id=61),
              Style(name='おどろき', id=62),
              Style(name='こわがり', id=63),
              Style(name='へろへろ', id=64)],
      speaker_uuid='1f18ffc3-47ea-4ce0-9829-0576d03a7ec8',
      version='0.14.4')]
```

# おわりに
LINE 上でずんだもんに喋ってもらうことができるようになりました。
これでずんだもんが恋しくなった時にいつでも声が聞けるようになりました。
今後は話者やスタイルも任意に選べるように拡張していきたいなと思います。
裏側に生成AIを仕込めば音声で質問に答えてくれるbotとかも作れたり、いろいろと試せそうです。