Pythonで始める録音機能付きDiscord Bot: (4) 音楽ファイルを再生する

# はじめに

この記事は前回[Pythonで始める録音機能付きDiscord Bot: (3) Databaseとの連携](https://qiita.com/Shirataki2/items/37d48868cd7b999a13b2)の続きです．

今回ではサーバー上にあらかじめ用意した音楽をコマンドで再生させるジュークボックス機能を追加します．discord.pyを用いれば音声の再生に関してはとても簡単な処理で実行することができます．ここでは単に再生するだけでなく曲のキューを実装して連続再生ができるような機能をつけることにします．

全7回を予定しており現在5記事まで執筆を終えています．

1. [Pythonで始める録音機能付きDiscord Bot: (1) 入門 discord.py](https://qiita.com/Shirataki2/items/3b9f9766bc25bb204ed3)
2. [Pythonで始める録音機能付きDiscord Bot: (2) 便利機能(Bot拡張,Cog,Embed)](https://qiita.com/Shirataki2/items/9bdc62548e7b24d3c803)
3. [Pythonで始める録音機能付きDiscord Bot: (3) Databaseとの連携](https://qiita.com/Shirataki2/items/37d48868cd7b999a13b2)
4. [Pythonで始める録音機能付きDiscord Bot: (4) 音楽ファイルを再生する](https://qiita.com/Shirataki2/items/f4ea533d5baf55c4b1d3)
5. [Pythonで始める録音機能付きDiscord Bot: (5) Discord APIを直接操作する](https://qiita.com/Shirataki2/items/58480517365a46922f45)

# Discordの音声通信

discord.pyでは直接いじることのない部分なので意識することはあまりありませんが，DiscordにはHTTPリクエストで投稿を送信したりサーバーの情報を取得したりする際に使用するREST APIのほかに，双方向的に通信するためのWebSocket通信，そして音声の送受信を行うRTP通信の3つの情報の送受信方法があります．

discord.pyの場合，ボイスチャンネルのインスタンスをContextなどから入手し，そのインスタンスの`connect`コルーチンを実行することによって音声チャンネルに接続するという非常に直感的な仕組みになっています．この`connect`より返されるものが`VoiceClient`クラスのインスタンスです．

`VoiceClient`において，上記のWebSocket通信やその他暗号化通信などといった部分を包み隠しています．ですので，サーバーごとのボイスチャンネルの情報を操作したいといった場合にはこの`VoiceClient`インスタンスに対して様々な操作を行えばよいのです．

複数のサーバーからのボイスチャンネルに対する処理を考えた場合，このインスタンスとサーバー(ID)を結びつける機構が必要となります．ここでは[discord.pyのリポジトリにある公式実装](https://github.com/Rapptz/discord.py/tree/master/examples)のように管理のために辞書配列を使用してVoiceClientを操作し，ローカル(Botを動かしているサーバー上)にある音楽の再生を試みます．

# VoiceChannelを操作する

## VoiceChannelに入室するまで

前述のようにVoiceChannelへの入退出は非常に簡単です．ここでは`Voice`というCogを作成し`$join`で入室し，`$leave`で退出することにします．実装の一例としては以下のようになります．

```python:./src/app/dbot/cogs/Voice.py
import discord
from discord.ext import commands
from typing import Dict
from dbot.core.bot import DBot


class Voice(commands.Cog):
    def __init__(self, bot: DBot):
        self.bot = bot
        self.voice_clients: Dict[int, discord.VoiceClient] = {}

    @commands.command()
    async def join(self, ctx: commands.Context):
        # VoiceChannel未参加
        if not ctx.author.voice or not ctx.author.voice.channel:
            return await ctx.send('先にボイスチャンネルに参加してください')
        vc = await ctx.author.voice.channel.connect()
        self.voice_clients[ctx.guild.id] = vc

    @commands.command()
    async def leave(self, ctx: commands.Context):
        vc = self.voice_clients.get(ctx.guild.id)
        if vc is None:
            return await ctx.send('ボイスチャンネルにまだ未参加です')
        await vc.disconnect()
        del self.voice_clients[ctx.guild.id]


def setup(bot):
    return bot.add_cog(Voice(bot))
```

[discord.VoiceChannel]: https://discordpy.readthedocs.io/ja/latest/api.html#voicechannel

`discord.Member`のプロパティである`voice`はメンバーがボイスチャンネルに参加している場合，VoiceStateクラスのインスタンスを返します．このVoiceStateはメンバーの消音等の状態のほかに`channel`というプロパティを持っており，これを参照することでメンバーの現在入室しているボイスチャンネルのクラス([discord.VoiceChannel])のインスタンスを得ることができます．

`VoiceChannel`の`connect`コルーチンを呼ぶことで`VoiceClient`が返され，Botがボイスチャンネルに参加します．その`VoiceClient`をサーバーIDをキーとして辞書に格納します．`$leave`では，逆にサーバーIDから`VoiceClient`を検索し，`disconnect`コルーチンを呼び出しています．入退出処理はこれで完了です．

## 音声を再生させる

まずは再生するための音楽を用意し，`./src/app/music/`フォルダ以下に保存します．

[![Image from Gyazo](https://i.gyazo.com/25cf5e860d3da18e3121f3c7976c9db4.png)](https://gyazo.com/25cf5e860d3da18e3121f3c7976c9db4)

名前をもとに検索するように実装するために適当な名前を付けておきます．ひとまず`$play 曲名`で再生`$stop`で停止を行います．

```python:./src/app/dbot/cogs/Voice.py
import discord
from glob import glob
import os
from discord.ext import commands
from typing import Dict
from dbot.core.bot import DBot


class Voice(commands.Cog):
    # 略

    @commands.command()
    async def play(self, ctx: commands.Context, *, title: str = ''):
        vc: discord.VoiceClient = self.voice_clients.get(ctx.guild.id)
        if vc is None:
            await ctx.invoke(self.join)
            vc = self.voice_clients[ctx.guild.id]
        music_pathes = glob('./music/**.mp3')
        music_titles = [
            os.path.basename(path).rstrip('.mp3')
            for path in music_pathes
        ]
        if not title in music_titles:
            return await ctx.send('指定の曲はありません．')
        idx = music_titles.index(title)
        src = discord.FFmpegPCMAudio(music_pathes[idx])
        vc.play(src)
        await ctx.send(f'{title}を再生します')

    @commands.command()
    async def stop(self, ctx: commands.Context):
        vc: discord.VoiceClient = self.voice_clients.get(ctx.guild.id)
        if vc is None:
            return await ctx.send('Botはまだボイスチャンネルに参加していません')
        if not vc.is_playing:
            return await ctx.send('既に停止しています')
        await vc.stop()
        await ctx.send('停止しました')

    # 略
```

duscird.pyを用いて音楽を再生するには，VoiceClientのPlayコルーチンにAudioSourceを渡す必要があります．AudioSourceの作成にはffmpegが必要です．ffmpegの環境が整っている場合，ファイルまでのパスを与えるだけでよく音楽の再生は非常に簡単に行えます．

再生を停止する場合も同様，vc.stopコルーチンを呼び出します．

## 連続再生を行う

現在の実装では，`$play`を呼び出すと直ちに音楽が切り替わるようになっています．これを，`$play`されたらキューに音楽を加えて前の音楽の再生が終えたら次の音楽を再生する仕様に変更します(いわゆるプレイリスト)．

プレイリスト実装のためには`asyncio.Queue`と`asyncio.Event`というものを活用します．それぞれ，キューに要素が追加されるまで待機する実装，よその個所でフラグが立つまで待機する実装をするためによく使用されます．

```python:./src/app/dbot/cogs/Voice.py
import discord
from glob import glob
import os
import asyncio
from discord.ext import commands
from typing import Dict
from dbot.core.bot import DBot


class AudioQueue(asyncio.Queue):
    def __init__(self):
        super().__init__(100)

    def __getitem__(self, idx):
        return self._queue[idx]

    def to_list(self):
        return list(self._queue)

    def reset(self):
        self._queue.clear()


class AudioStatus:
    def __init__(self, ctx: commands.Context, vc: discord.VoiceClient):
        self.vc: discord.VoiceClient = vc
        self.ctx: commands.Context = ctx
        self.queue = AudioQueue()
        self.playing = asyncio.Event()
        asyncio.create_task(self.playing_task())

    async def add_audio(self, title, path):
        await self.queue.put([title, path])

    def get_list(self):
        return self.queue.to_list()

    async def playing_task(self):
        while True:
            self.playing.clear()
            try:
                title, path = await asyncio.wait_for(self.queue.get(), timeout=180)
            except asyncio.TimeoutError:
                asyncio.create_task(self.leave())
            src = discord.FFmpegPCMAudio(path)
            self.vc.play(src, after=self.play_next)
            await self.ctx.send(f'{title}を再生します...')
            await self.playing.wait()

    def play_next(self, err=None):
        self.playing.set()

    async def leave(self):
        self.queue.reset()
        if self.vc:
            await self.vc.disconnect()
            self.vc = None

    @property
    def is_playing(self):
        return self.vc.is_playing()

    def stop(self):
        self.vc.stop()


class Voice(commands.Cog):
    def __init__(self, bot: DBot):
        self.bot = bot
        self.audio_statuses: Dict[int, AudioStatus] = {}

    @commands.command()
    async def join(self, ctx: commands.Context):
        # VoiceChannel未参加
        if not ctx.author.voice or not ctx.author.voice.channel:
            return await ctx.send('先にボイスチャンネルに参加してください')
        vc = await ctx.author.voice.channel.connect()
        self.audio_statuses[ctx.guild.id] = AudioStatus(ctx, vc)

    @commands.command()
    async def play(self, ctx: commands.Context, *, title: str = ''):
        status = self.audio_statuses.get(ctx.guild.id)
        if status is None:
            await ctx.invoke(self.join)
            status = self.audio_statuses[ctx.guild.id]
        music_pathes = glob('./music/**.mp3')
        music_titles = [
            os.path.basename(path).rstrip('.mp3')
            for path in music_pathes
        ]
        if not title in music_titles:
            return await ctx.send('指定の曲はありません．')
        idx = music_titles.index(title)
        await status.add_audio(title, music_pathes[idx])
        await ctx.send(f'{title}を再生リストに追加しました')

    @commands.command()
    async def stop(self, ctx: commands.Context):
        status = self.audio_statuses.get(ctx.guild.id)
        if status is None:
            return await ctx.send('Botはまだボイスチャンネルに参加していません')
        if not status.is_playing:
            return await ctx.send('既に停止しています')
        await status.stop()
        await ctx.send('停止しました')

    @commands.command()
    async def leave(self, ctx: commands.Context):
        status = self.audio_statuses.get(ctx.guild.id)
        if status is None:
            return await ctx.send('ボイスチャンネルにまだ未参加です')
        await status.leave()
        del self.audio_statuses[ctx.guild.id]

    @commands.command()
    async def queue(self, ctx: commands.Context):
        status = self.audio_statuses.get(ctx.guild.id)
        if status is None:
            return await ctx.send('先にボイスチャンネルに参加してください')
        queue = status.get_list()
        songs = ""
        for i, (title, _) in enumerate(queue):
            songs += f"{i+1}. {title}\n"
        await ctx.send(songs)


def setup(bot):
    return bot.add_cog(Voice(bot))
```

VoiceClientとサーバーの情報を一緒に登録するために新たに`AudioStatus`というクラスを作成し，そのインスタンスを保存します．

`AudioStatus`では初期化時に`asyncio.create_task`という関数を呼び出しています．名前の通り，音楽再生を行うタスクをコルーチンから作成しています．こうすることで，タスクの方で別の処理をしながら他のサーバーからのコマンドに応答するなどといった非同期的なアクセスを可能にしています．`playing`というプロパティは`asyncio.Event`ですがこれは特定の条件を満たした際にフラグを立ててフラグが立つまでは何もせずに待機したいといった際に使われます．`playing_task`では`clear`,`wait`を，`play_next`では`set`を呼び出していますが，それぞれ「フラグを`False`にする」，「フラグが`True`になるまで待つ」，「フラグを`True`にする」という処理です．`play_next`は`vc.play`のafter引数に渡していますが，これは一曲の再生が終わった際に行いたい処理を引数に与えています．曲の終わり時に次曲再生のフラグを立てることで再度`playing_task`のループが開始されます．

この`AudioStatus`の`queue`プロパティは`asyncio.Queue`を継承し，曲目リストの取得などを容易にしたものですがこの`asyncio.Queue`は`get`呼び出し時に返す値がないような際にはその場で新しい要素が追加されるまでそこで待機します．これにより，曲目入力の待機時間を設けることが可能となります．そして3分の間入力がない場合には`leave`コルーチンを呼び出して自動的にボイスチャンネルから退出します．

[![Image from Gyazo](https://i.gyazo.com/d0933f8f0e71d4a96da7d9d9786d0d88.png)](https://gyazo.com/d0933f8f0e71d4a96da7d9d9786d0d88)

これにより，曲のキューを備えた音楽再生機能を追加することが可能となりました．

# 終わりに

これで音楽再生機能は実装出来ました！簡単に済んでよかったです！音声録画機能も同じくらい簡単だよね？？？

というわけにはいかないようなので，録音機能を実装するにあたっての準備としてDiscordのAPI機能を直接触ってどのように音声の送受信を扱っているかを詳しく見てdiscord.pyを使用せずに実装します．