Pycord 読み上げBot開発

## 初めに
今回はVoiceVoxとPycordを使って読み上げボットを作っていきます。
前回の記事を読んでいる前提ですので、環境構築などは省略させていただきます。
誤字脱字、内容が一部不正確な可能性があります。ご了承ください。

今回作成するプログラムはFFmpegをインストールしていないと動作しません。
FFmpegのインストール方法とパスの通し方は省略させていただきます。

## VoiceVox
まずはVoiceVoxを以下のリンクからインストールしてください。

https://voicevox.hiroshiba.jp/

VoiceVoxの実態はHTTPサーバーを起動するソフトなので、リクエストを送ることで音声合成したデータを取得することができます。

まずは確認してみましょう。
VoiceVoxを起動してブラウザで次のURLに移動してください。

 http://127.0.0.1:50021/docs

ここではVoiceVoxのドキュメントを確認することができます。
```/audio query```を確認してみましょう。

見てみると```Parameters```という欄があると思います。
ここではリクエストを送る際のパラメータを確認することができます。

赤字で<font color="red">```* required```</font>と書かれているところは必須のパラメータになっていて、これを設定し忘れると正常にレスポンスが返ってきません。

次は実際にプログラムを書いて確認していきます。

## リクエストを送ってみる
### 環境構築

まずはコマンドプロンプトを起動して以下のコードを実行しましょう。
```console
$ pip install requests
```
これはPythonでHTTP通信を可能にする外部ライブライをインストールしています。

### コード

リクエストを送って結果を確認するコードです。
実行して```<Response [200]>```と表示されるとリクエストを送ることに成功しています。

```python
import requests

def post_audio_query(text: str, speaker: int):
    URL = "http://127.0.0.1:50021/audio_query"
    Parameters = {
        "text": text,
        "speaker": speaker
    }

    response = requests.post(URL, params=Parameters)

    return response

print(post_audio_query("おはよう", 1))
```
### 解説
上から説明していきます。
```python
import requests
```
ここでは先ほどインストールしたライブラリをインポートしています。
PythonでHTTP通信をするために使われるライブラリなのでリクエストを送るためには必須となります。
```python
def post_audio_query(text: str, speaker: int):
```
ここでは関数名と引数を指定しています。
関数名は何でもいいですがわかりやすいのが良いかと思われます。

引数は先ほどdocsで確認した必須パラメータを指定できるようにしています。
```python
    URL = "http://127.0.0.1:50021/audio_query"
    Parameters = {
        "text": text,
        "speaker": speaker
    }
```

ここは変数にURLとパラメータを設定しています。

URLは```audio_query```にリクエストを送りたいなら
```http://127.0.0.1:50021/audio_query```

音声合成のリクエストを送りたいなら
```http://127.0.0.1:50021/synthesis```

のように変化します。
ただ、URLを変更すると必須パラメータが変わりますのでdocsからしっかりと確認してください。

```Parameters```のところは辞書と呼ばれる形で、**{}**　のなかにキーと値をセットで指定します。

今回は必須パラメータである```text```と```speaker```に引数を指定しているのが分かると思います。

```python
    response = requests.post(URL, params=Parameters)

    return response
```

ここでは実際にリクエストを送ってレスポンスを取得しています。
リクエストを送った結果を```response```に代入して、結果を```return```で返しています。

```python
print(post_audio_query("おはよう", 1))
```
ここでは関数を実行して帰ってきた結果を```print```でターミナルに表示しています。
もし```<Response [200]>```以外が表示されると、どこかしら間違えているので確認してください。
## Botをボイスチャットに接続する。
### 環境構築
前回の記事では音声サポートなしのpycordをインストールしましたが、今回はボイスチャットを使うということで音声サポート有にアップデートします。

```console
$ pip install -U py-cord[voice]
```

### Botの設定
デベロッパーポータルのBotの権限の設定をしていきます。
今回開発するのは読み上げボットなので、テキストチャンネルにアクセスして文字列を取得する必要があります。

前回の設定のままだとメッセージを取得することができないので、トークンを取得したページの下のほうにある**Privileged Gateway Intents**から以下の画像のように機能を有効にしてください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1840949/7a090a4f-a9cb-fdda-9220-279a890b437a.png)


設定を保存したら完了です。

### コード
ボットをボイスチャンネルに接続していきます。
コマンドは```/join```にします。

```python
import discord

Token = 'トークン'

intents = discord.Intents.default()
intents.voice_states = True
bot = discord.Bot(intents=intents)
voice_client = None

@bot.event
async def on_ready():
    print("起動しました。")

@bot.slash_command()
async def join(ctx):
    global voice_client
    user = ctx.author
    if not user.voice:
        await ctx.respond("ボイスチャンネルに接続していません。")
        return
    await ctx.respond("ボイスチャンネルに接続しました。")
    voice_channel = user.voice.channel
    voice_client = await voice_channel.connect()

@bot.slash_command()
async def left(ctx):
    global voice_client
    if not voice_client:
        await ctx.respond("ボイスチャンネルに接続していません。")
        return
    await ctx.respond("切断しました。")
    await voice_client.disconnect()

bot.run(Token)
```

### 解説
#### インテント

前回説明した部分は省略していきます。

```python
intents = discord.Intents.default()
intents.voice_states = True
bot = discord.Bot(intents=intents)
 ```

今回はインテントというものを指定しています。

インテントはDiscordからイベントを受け取るかどうか指定するもので、文章を取得する場合は
```python 
intents.message_content = True
 ```
といった感じになります。
今回指定している```voice_states```というのは、サーバーのボイスチャットの状態を取得するためのものです。

#### グローバル変数

```python
voice_client = None
```

コマンドの指定などは前回と同じですが、今回はグローバル変数というものを扱っています。

グローバル変数とはプログラム全体で使うことできる変数のことで、プログラムのどこからでもアクセスすることが可能です。

また、関数内で扱うときには以下のように指定しなければなりません。
```python
global voice_client
```

なぜかというと、関数内ではグローバル変数とローカル変数は区別がつかないからです。
そのため、グローバル変数を指定する文を書かないと関数内で新しくローカル変数が作成されてしまいます。
```python
voice_client = await voice_channel.connect()
```
ここで記述している、```connect()```は実行時に```VOICECLIENT```という型を返す特性があります。
その帰ってきた型を、グローバル変数に代入しているというわけです。

### 実行
実際に実行してみましょう。
もし正常に動作しているならば```/join```と書いたときにボットばボイスチャンネルに接続してきます。

![Discord_1Kgxhs5JCs.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1840949/d2ab2781-e416-00e7-0026-24cf67ad03d5.png)

![Discord_kxoi5ADrUA.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1840949/1b749179-cb6c-a72d-4d9d-20e16f140a70.png)
正常に実行できているならば画像のように動作します。
```/left```と入力することで、ボットをボイスチャンネルから切断することができるので、そちらも同じように確認しておいてください。

## 読み上げボットを作る。
これまで、読み上げボットを作るために必要なプログラムの説明をしてきたので実際に１つにしていきます。

### コード
```python
import discord
import requests
import tempfile

Token = 'トークン'

intents = discord.Intents.default()
intents.voice_states = True
intents.message_content = True
bot = discord.Bot(intents=intents)
voice_client = None
text_channel = None

def post_audio_query(text: str, speaker: int):
    URL = "http://127.0.0.1:50021/audio_query"
    Parameters = {
        "text": text,
        "speaker": speaker
    }

    response = requests.post(URL, params=Parameters)

    return response.json()

def post_synthesis(json: dict, speaker: int):
    URL = "http://127.0.0.1:50021/synthesis"

    Parameters = {
        "speaker": speaker
    }
    response = requests.post(URL,json=json, params=Parameters )

    return response.content

def save_tempfile(text: str, speaker: int):
    json = post_audio_query(text, speaker)
    data = post_synthesis(json, speaker)

    with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as wf:
        wf.write(data)
        wf.close()

        path = wf.name

    return path

@bot.event
async def on_ready():
    print("起動しました。")

@bot.event
async def on_message(message):
    if message.author.bot == True:
        return
    global text_channel
    global voice_client
    channel = message.channel
    if not text_channel == channel:
        return
    path = save_tempfile(message.content, 1)
    voice_client.play(discord.FFmpegPCMAudio(path))

@bot.slash_command()
async def join(ctx):
    global voice_client
    global text_channel
    user = ctx.author

    if not user.voice:
        await ctx.respond("ボイスチャンネルに接続していません。")
        return
    await ctx.respond("ボイスチャンネルに接続しました。")
    text_channel = ctx.channel
    voice_channel = user.voice.channel
    voice_client = await voice_channel.connect()
    path = save_tempfile("接続しました", 1)
    voice_client.play(discord.FFmpegPCMAudio(path))

@bot.slash_command()
async def left(ctx):
    global voice_client
    global text_channel
    if not voice_client:
        await ctx.respond("ボイスチャンネルに接続していません。")
        return
    await ctx.respond("切断しました。")
    await voice_client.disconnect()
    voice_client = None
    text_channel = None

bot.run(Token)
```
### 解説
```python
import tempfile
```
今回はtempfileというライブラリを使用しています。これは、ファイルを一時保存するためのライブラリでVoiceVoxを通して作成したwavファイルを一時保存し、discordで再生しているというわけです。

```python
intents.message_content = True
```
インテントは先ほど紹介した通りです。ただ、今回はメッセージの内容を取得してもらうので```message_content```をTrueにしています。

```python
def post_audio_query(text: str, speaker: int):
    URL = "http://127.0.0.1:50021/audio_query"
    Parameters = {
        "text": text,
        "speaker": speaker
    }

    response = requests.post(URL, params=Parameters)

    return response.json()

def post_synthesis(json: dict, speaker: int):
    URL = "http://127.0.0.1:50021/synthesis"

    Parameters = {
        "speaker": speaker
    }
    response = requests.post(URL,json=json, params=Parameters )

    return response.content

def save_tempfile(text: str, speaker: int):
    json = post_audio_query(text, speaker)
    data = post_synthesis(json, speaker)

    with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as wf:
        wf.write(data)
        wf.close()

        path = wf.name

    return path
```

ここでVoiceVoxから音声データを取得しています。```save_tempfile```関数は上２つの関数を実行して、その結果をtempfileに保存してそのファイルのパスを返しています。

```python
@bot.event
async def on_message(message):
```
```on_message```はメッセージが送信されたときに呼ばれるイベントです。
```python
if message.author.bot == True:
    return
```
この文章は発言者がボットかどうかを判別し、もしボットであった場合は処理を終了しています。```on_message```イベントはボット自身が文章を更新したときも呼ばれるので、この処理がなければ無限ループが発生してしまいます。

```python
channel = message.channel
if not text_channel == channel:
    return
```
ここではメッセージチャンネルがコマンドが実行されたチャンネルと同じかどうかを判別しています。
text_channelの初期値はNoneなので、```/join```を実行する前や```/left```を実行した後はこれ以上処理が進むことがありません。
```python
path = save_tempfile(message.content, 1)
voice_client.play(discord.FFmpegPCMAudio(path))
```
ここで音声ファイルのパスを取得してdiscordで再生しています。
何度も言いますがFFmpegをインストールしていないの**動作しない**ので気を付けてください。
## 終わりに
お疲れさまでした。
後半は解説が駆け足になってしまったかもしれません。
メッセージが取得できないなどのことがあればインテント関連だと思うので、読み上げボットを作成するときだけに限らずインテントの指定には気を付けてください。