MyCOEIROINKで読み上げるDiscordBot

![2024-12-05_012005.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3946861/0e3b16c7-6b8e-f1c3-c8ef-f66181592f3c.png)

# 1.初めに
この記事ではCOEIROINKの音声合成・読み上げをDiscordBotとして実装する部分を詳しく解説します。動きとしては、Discordに投稿されたテキストを取得し、COEIROINKで生成した音声をボイスチャット上で再生する感じです。MyCOEIROINKライブラリの作成、COEIROINKのPythonでの基本的な動かし方などは、その都度紹介する記事を参考にしてください。
# 2.MyCOEIROINKとは
MyCOEIROINKは、自分で用意した音声データセットを機械学習を用いて作成する、自作のCOEIROINKライブラリです。※利用規約を順守して作成してください。
今回は了承を得て、なぜか声優並みに発声が良い知人の声を借りました。

https://colab.research.google.com/drive/1BqaB-Zv5RuaQp-OW0effsFVGCYwvaJ4R?usp=sharing

参考：

https://youtu.be/q9PrZ4p4fuA?si=Ib2acAyxu8yo7IXT
# 3.MyCOEIROINKをPythonで実装する
自作したライブラリを扱うには音声合成クエリのパラメータの"speakerUuid"と"styleId"を指定する必要があります。自作ライブラリ一式をCOEIROINKのspeaker_infoに入れた状態で以下のコードを実行してください。
```python
import json
import requests

API_SERVER = "http://127.0.0.1:50032"


def save_speakers():

    #利用可能なSpeaker情報をファイル出力する
    
    speakers = []

    response = requests.get(
        f"{API_SERVER}/v1/speakers",
    )

    for item in json.loads(response.content):
        speaker = {
            "speakerName": item["speakerName"],
            "speakerUuid": item["speakerUuid"],
            "styles": [
                {"styleName": style["styleName"], "styleId": style["styleId"]}
                for style in item["styles"]
            ],
            "version": item["version"],
        }

        speakers.append(speaker)

    # 出力ファイルに保存
    with open("speakers.json", "w", encoding="utf-8") as f:
        json.dump(speakers, f, ensure_ascii=False, indent=4)


save_speakers()
```
実行すると以下のようなjsonファイルが生成されます。
```json
[
    {
        "speakerName": "でじこんちゃん Ver.1.0.0",
        "speakerUuid": "f0c7655a-4a51-11ef-8a6a-0242ac1c000c",
        "styles": [
            {
                "styleName": "のーまる",
                "styleId": 780091868
            }
        ],
        "version": "0.0.1"
    },
    {
        "speakerName": "つくよみちゃん",
        "speakerUuid": "3c37646f-3881-5374-2a83-149267990abc",
        "styles": [
            {
                "styleName": "れいせい",
                "styleId": 0
            }
        ],
        "version": "0.0.1"
    }
]
```
ここではデフォルトで入っている「つくよみちゃん」とspeaker_infoに入っている自作ライブラリの"speakerName"、"speakerUuid"、"styleId"を取得しています。クエリ（使用するライブラリ）のパラメータは、
```python
query = {
        "speakerUuid": "f0c7655a-4a51-11ef-8a6a-0242ac1c000c",
        "styleId": 780091868,
        "text": text,
        "speedScale": 1.0,
        "volumeScale": 1.0,
        "prosodyDetail": [],
        "pitchScale": 0.0,
        "intonationScale": 1.0,
        "prePhonemeLength": 0.1,
        "postPhonemeLength": 0.5,
        "outputSamplingRate": 24000,
    }
```
のように指定するので、適切な"speakerUuid"と"styleId"を適用しましょう。話す速さやピッチなどの細かいパラメータも指定できるので、好みの値を設定してください。
設定したパラメータを元に以下のコードで音声合成できます。
```python
#音声合成を実行
response = requests.post(
	"http://127.0.0.1:50032/v1/synthesis",
	headers={"Content-Type": "application/json"},
	data=json.dumps(query),
)

response.raise_for_status()
#生成した音声をwavファイルに保存
with open("audio.wav", "wb") as f_temp:
	f_temp.write(response.content)
```

参考：

https://note.com/npaka/n/n20763cb96cfb

https://note.com/filledteapot/n/n0e2b4148d948
# 4.生成した音声をDiscordVCで再生する
この章がこの記事のメインとなります。生成した音声をBotを通してDiscordのVCで流せば読み上げボットの実装となります。DiscordBotアカウントの作成方法や基本的なPythonでの書き方はこちらを参考にしてください。
参考：

https://zenn.dev/yumax_panda/articles/bot-tutorial-1

以下のコードがこの読み上げボットの中身となります。一章に記述した通り、流れとしてはディスコードに送信されたテキストの内容をCOEIROINKで読み上げてもらい、その音声をディスコードのVCで再生するといったものになります。
```python
import json
import requests
from discord.ext import commands
import discord
from io import BytesIO
import re  # URLの検出に使用

TOKEN = 'ここにトークンを貼り付ける'

# 音声合成APIを使って音声ファイルを生成する関数
def talk(text):
    # リクエストボディ 
    text = re.sub(r'<.*?>', '', text)
    
    query = {
        "speakerUuid": "f0c7655a-4a51-11ef-8a6a-0242ac1c000c",
        "styleId": 780091868,
        "text": text,
        "speedScale": 1.0,
        "volumeScale": 1.0,
        "prosodyDetail": [],
        "pitchScale": 0.0,
        "intonationScale": 1.0,
        "prePhonemeLength": 0.1,
        "postPhonemeLength": 0.5,
        "outputSamplingRate": 24000,
    }

    # 音声合成を実行
    response = requests.post(
        "http://127.0.0.1:50032/v1/synthesis",
        headers={"Content-Type": "application/json"},
        data=json.dumps(query),
    )

    response.raise_for_status()

    # 音声をメモリ内に保存し、返す
    return BytesIO(response.content)

# URLを検出し、テキストがURLのみの場合は「リンク省略」として返す関数
def process_message(text):
    url_pattern = r'https?://\S+|www\.\S+'  # URLパターン

    # テキストが完全にURLだけで構成されている場合は「リンク省略」を返す
    if re.fullmatch(url_pattern, text):
        return 'リンク省略'

    # それ以外の場合はURL部分のみを「リンク省略」に置き換える
    return re.sub(url_pattern, 'リンク省略', text)

# インテントを設定
intents = discord.Intents.default()
intents.message_content = True  # メッセージ内容を取得するために必要
intents.voice_states = True      # ボイスチャンネルの状態を取得するために必要
intents.members = True  # メンバー情報を取得するために必要

bot = commands.Bot(command_prefix="/", intents=intents)

@bot.event
async def on_ready():
    print('Botが起動しました')

@bot.command(name="join")
async def join(ctx: commands.Context):
    # ユーザーがVCに接続しているか確認
    if ctx.author.voice is None:
        await ctx.send("あなたはボイスチャンネルに接続していません。")
        return
    
    # ボットをボイスチャンネルに接続
    channel = ctx.author.voice.channel
    await channel.connect()
    await ctx.send("ボイスチャンネルに接続しました！")

@bot.command(name="leave")
async def leave(ctx: commands.Context):
    # ボイスチャンネルからボットを切断
    if ctx.voice_client is not None:
        await ctx.voice_client.disconnect()
        await ctx.send("ボイスチャンネルから切断しました。")
    else:
        await ctx.send("ボットはどのボイスチャンネルにも接続していません。")

@bot.event
async def on_message(message: discord.Message):
    # ボット自身が送信したメッセージには反応しない
    if message.author.bot:
        return

    # メッセージ内のURLを処理（URLがあれば「リンク省略」に置き換え）
    processed_message = process_message(message.content)

    # ボットがボイスチャンネルに接続していれば、メッセージ内容を読み上げ
    if message.guild.voice_client and message.guild.voice_client.is_connected():
        audio_data = talk(processed_message)  # URLが置き換えられたメッセージを音声合成
        with open("output.wav", "wb") as f:
            f.write(audio_data.read())

        # 音声ファイルを再生
        message.guild.voice_client.play(discord.FFmpegPCMAudio("output.wav"),
        after=lambda e: print("再生完了"))

    await bot.process_commands(message)  # コマンド処理も続けて行う
    
bot.run(TOKEN)
```
細かい仕様説明です。
誰かがVCにいるならば"/join"のコマンドをテキストチャットに送ることで、ボットがVCに入ります。
![スクリーンショット 2024-12-05 131106.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3946861/75fc91b1-db32-7d93-5c06-4b3f0fa3a4b4.png)
```python
@bot.command(name="join")
async def join(ctx: commands.Context):
    # ユーザーがVCに接続しているか確認
    if ctx.author.voice is None:
        await ctx.send("あなたはボイスチャンネルに接続していません。")
        return
    
    # ボットをボイスチャンネルに接続
    channel = ctx.author.voice.channel
    await channel.connect()
    await ctx.send("ボイスチャンネルに接続しました！")

```
ボットがVCに入っている時、"/leave"のコマンドをテキストチャットに送ることで、ボットをVCから切断します。
![スクリーンショット 2024-12-05 131944.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3946861/1316a1c5-e824-5ee7-b32a-62105d24f9dd.png)

```python
@bot.command(name="leave")
async def leave(ctx: commands.Context):
    # ボイスチャンネルからボットを切断
    if ctx.voice_client is not None:
        await ctx.voice_client.disconnect()
        await ctx.send("ボイスチャンネルから切断しました。")
    else:
        await ctx.send("ボットはどのボイスチャンネルにも接続していません。")
```
ここが、テキストチャットに送信されたメッセージを取得し、COEIROINKに読み上げるテキストを渡す部分です。今回はサーバーの全てのチャンネルのテキストを取得していますが、チャンネルIDを指定することで、特定のチャンネルだけのテキストを読み上げるようにすることが出来ます。
```python
@bot.event
async def on_message(message: discord.Message):
    # ボット自身が送信したメッセージには反応しない
    if message.author.bot:
        return

    # メッセージ内のURLを処理（URLがあれば「リンク省略」に置き換え）
    processed_message = process_message(message.content)

    # ボットがボイスチャンネルに接続していれば、メッセージ内容を読み上げ
    if message.guild.voice_client and message.guild.voice_client.is_connected():
        audio_data = talk(processed_message)  # URLが置き換えられたメッセージを音声合成
        with open("output.wav", "wb") as f:
            f.write(audio_data.read())

        # 音声ファイルを再生
        message.guild.voice_client.play(discord.FFmpegPCMAudio("output.wav"), after=lambda e: print("再生完了"))

    await bot.process_commands(message)  # コマンド処理も続けて行う
```
KuronekoServerさんの読み上げボットを参考に、URLが貼られた時にリンクを全て読んでしまうことがないよう、
```python
processed_message = process_message(message.content)
```
のコードでURLは読み上げないような処理が入っています。
また、絵文字が貼られた時に絵文字IDを読み上げてしまわないよう、
```python
text = re.sub(r'<.*?>', '', text)
```
"<絵文字ID>"を空欄に変えて読まないようにしています。

# 5.あとがき
いかがでしたでしょうか。技術ログどころかまともに記事を書くのが初めてなので、文章が稚拙で読みづらかったと思いますがご容赦くださいませ。今回、MyCOEIROINKを使った読み上げボットを作った経緯ですが、どうしてもお借りした知人の声をデータとして残したかったのです。人は声から忘れると言うものですから...ならば、現代技術を用いて日常的にその声聞けるものに昇華させたいと考えました。MyCOEIROINKはITAコーパスを読んだ音声データをファインチューニングすることで、好きな声のライブラリを作成しているわけですが、想像以上の声の再現度に驚きました。特に、抑揚の癖や滑舌が顕著に表れているのは機械学習の特徴ですね。