MPV+PulseAudioでDiscord音楽ボットに新たな可能性を作る

現存するDiscordの音楽ボットには、一時停止やリピートができるもの、音量を変更できるものがたくさん存在します。
しかしシークができたり再生速度を変えられるというボットは今まで聞いたことがありません。
ならば作ってみようということで、まずはどういう仕組みにするか考えることにしました。
この記事では[Discord.py](https://github.com/Rapptz/discord.py)を使用します。
OSはUbuntu 20.04 LTSを使用します。

# 仕組みを考える
## そもそも単体で完結させるのはちょっと厳しい
Discord.pyやDiscord.jsは音声を送信する過程（音声ファイルのデコード等）にFFmpegを使用しています。
ここはどうしても外部に頼らざるを得ない部分でもあります。
さらにここに、今目標としているシーク機能や再生速度を変える機能を実装するとなると、時間がかかって効率が悪くなってしまいます。既存のソフトウェアをうまく組み合わせたほうが早く実現できそうです。

## 既存のソフトウェアを使う
DiscordボットはGUIのある環境で動かすことはほぼありません。そのため、コンソールしか使えない環境でも動かせる再生ソフトを探していきます。
そこで真っ先に浮かんだのは[MPV](https://mpv.io/)という再生ソフトです。もちろんコンソールで動きます。
MPVは以前からよく使っていてヘルプも一通り見ていました。MPVはプロセス間通信（IPC)を使ってプログラムから再生状態や再生速度を制御することができます。さらに、FFmpegベースのプレイヤーであるため、多くのコーデックをサポートしています。今回はこれを使うことにしました。

MPVはUbuntuの標準リポジトリにあるのでインストールします。

```bash
sudo apt install mpv
```

# 設計
MPVは出力方法として[PCMデータを直接出力する](https://mpv.io/manual/master/#audio-output-drivers-pcm)というものがあります。 (`--ao=pcm`)
出力先をstdoutにすれば、一時ファイルを用意しなくても直接受け取れます。
プログラム側では、出力されたPCMデータを受け取ってエンコードしてDiscordに送信するという仕組みになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/196404/f303f3b8-ea16-1dde-9bf6-2d2aa264f01d.png)
Discord.pyでは音声送信の元になるものをAudioSourceと呼んでおり、基底クラスが用意されています。（デフォルトで用意されているFFmpegを経由するソースもこの基底クラスを継承しています）
これを継承して`MPVSource`というクラスを作成しました。

AudioSourceのライフサイクルは次のとおりです。
> 1. `__init__()` が呼ばれクラスが作成される（ここで再生の準備等を行う）
> 2. `read()` が呼ばれるので20ms分の音声データを返す (空のバイト列を返すことで再生が終了したことを示す)
> 3. 再生終了後 `cleanup()` が呼ばれるので、ここでデータの破棄等を行う

```python
class MPVSource(discord.AudioSource):
    def __init__(self, soruce: Union[str, io.BufferedIOBase], executable_path:str = '/usr/bin/mpv'):
```

着々と実装を進めていたのですが、ここで問題が発生。
再生はできるものの、IPCでの制御が効かないのです。コマンドを送っても無反応。プログラムの書き方が悪いのかと思って30分ぐらい悩んでました。

デバッグする中で判明したのは **PCMデータを直接出力するときは、一気に処理してstdoutに流してしまうこと** です。これでは制御ができません。
こうなると、通常のオーディオ出力を使うしか方法がなくなってしまいます。
## PulseAudioを使う
PCMをMPVに直接吐かせるという方法が使えないと分かったので急遽作戦変更。
PulseAudioの仮想デバイスを作成し、MPVはそこに音声を流してボット側では録音して受け取るという方法を使うことにしました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/196404/ce71cf31-6c7c-7ee6-6b59-e7411911b7a5.png)
ほぼ力技ですね。

まずPulseAudioをインストールします。
```bash
sudo apt install pulseaudio
```
PulseAudioは一定時間接続がないと止まってしまうので設定を変えて止まらないようにします。

```conf:/etc/pulse/daemon.conf
# (途中省略)
exit-idle-time = -1
```

それからPulseAudioを起動します。
```bash
pulseaudio --start
```

### 仮想デバイスを作成する
PulseAudioではNull SinkというVB-Audio Cableのような仮想デバイスが用意されています。いくらでも作れます。Sinkは直訳で「台所の流し」を意味し、意味通り音声を出力するものを指します。
端末で`pactl load-module module-null-sink sink_name=DummySink1`を実行すると`DummySink1`という仮想デバイスが作られます。
このコマンドをボットから実行すればよさそうです。
シンクの名前は`discord-[UUIDv4のID]`にします。

`__init__()` の中で仮想デバイスを作成します。

```python
def __init__(self, source: Union[str, io.BufferedIOBase], executable_path:str = '/usr/bin/mpv'):
        self.pa_sink_name = f'discord-{uuid.uuid4()}'
        self._pa_sink_volume: int = 65535

        # PulseAudio上に仮想デバイスを作成する
        try:
            ret, module_id, _null = self._run_shell(['pactl', 'load-module', 'module-null-sink', 'sink_name=' + self.pa_sink_name])
        except:
            raise PADeviceRegisterException('Failed to register virtual device')
        
        if ret != 0:
            raise PADeviceRegisterException('Failed to register virtual device')
        
        self._pa_module_id:int = int(module_id.decode())
```

`_run_shell()` は単にコマンドを実行するための関数なのでここでは記載しません。
`self._pa_module_id` はクリーンアップ時に仮想デバイスを削除するために必要です。load-moduleを実行したときの返り値はモジュール番号なので、変数に格納します。

### parecordを使って出力を拾う
MPVで仮想デバイスに音声を出力するよう指定しているので、ボット側で音声を受け取るにはparecordというコマンドを使う必要があります。PulseAudio付属のコマンドラインツールです。
先に書くと `parecord -r --raw --rate=48000 --channels=2 --format=s16le --device=デバイス名.monitor` というコマンドラインになります。`-r` で録音モードにして、サンプリング周波数や量子化ビット数の設定などを行っています。デバイスの指定でデバイス名の後ろに`.monitor`を付けているのはなぜかというと、`デバイス名.monitor`という仮想デバイスが聴く用に存在するからです（ループバック）。`.monitor` なしの方は出力専用なので、「録音デバイスじゃないよ」と怒られます。

`__init__()`でparecordを起動して、`read()`で受け取る処理を追加します。

```python
def __init__(self, source: Union[str, io.BufferedIOBase], executable_path:str = '/usr/bin/mpv'):
    # (途中省略)
    self._parecord_args = ['/usr/bin/parecord',
        '-r',
        '--raw',
        '--rate=48000',
        '--channels=2',
        '--format=s16le',
       f'--device={self.pa_sink_name}.monitor'
    ]
    self._parecord_process = subprocess.Popen(self._parecord_args, stdout=subprocess.PIPE)
```

```python
def read(self) -> bytes:
        frame = self._parecord_process.stdout.read(OpusEncoder.FRAME_SIZE)
        
        if len(frame) != OpusEncoder.FRAME_SIZE:
            return b''

        return frame
```

## MPVを起動する
`__init__()`の中でMPVを起動します。

```python
def __init__(self, source: Union[str, io.BufferedIOBase], executable_path:str = '/usr/bin/mpv'):
    # (途中省略)
    self._ipc_sock_path = '/tmp/dpy_mpv_' + str(uuid.uuid4()) + '.sock'
    self._mpv_args = [executable_path,
          '--msg-level=all=error',
          '--no-cache',
          '--no-cache-pause',
          '--demuxer-readahead-secs=0',
          '--no-video',
          '--no-audio-display',
          '--input-ipc-server=' + self._ipc_sock_path,
          '--ao=pulse',
         f'--audio-device=pulse/{self.pa_sink_name}',
          '--audio-format=s16',
          '--audio-samplerate=48000',
          '--audio-channels=stereo',
          source
    ]
    self._mpv_process = subprocess.Popen(self._mpv_args, stdout=subprocess.PIPE)
```

注意点として、ここで再生デバイスを指定しないと、あるサーバーで音楽を再生しているときに他のサーバーで再生している音楽が混じるという事が当然ながら起きてしまいます。仮想デバイスをサーバーごとに作っている意味がありません。
オプションで `--ao=pulse --audio-device=pulse/デバイス名` を指定すると、`デバイス名` に音声が流れます。(他のネット記事では `--ao=pulse::デバイス名` という指定のしかたが紹介されていましたが、現行バージョンでは廃止されており `--audio-device=DEVICE` を使うことになっています)

その他、ログ出力を抑制したり遅延を減らすオプションを指定しています。

## MPVのIPCサーバーに繋いでコマンドを送れるようにする
冒頭で書いた通り、MPVはIPCで再生制御ができます。
ここではUNIXソケットを使います。
オプションに `--input-ipc-server=ソケットファイルへのパス` を指定すると有効化されます。
パスはプログラム側で動的に作成します。 `/tmp` 以下に置きます。

そして、コマンドを送るための`send_cmd()`を用意します。

```python
def send_cmd(self, cmd: str):
    _ipc = socket.socket(AF_UNIX, SOCK_STREAM)
    _ipc.connect(self._ipc_sock_path)
    _ipc.send((cmd + '\n').encode())
    _ipc.close()
```

コマンドの後ろに`\n`を入れないと受け付けてくれません。
注意：**改行があったり、`;` があると１コマンドと認識されて複数実行できるため、ユーザーの入力を適切に処理しないと意図せず他のコマンドが実行され、ホストOSが操作されるおそれがあります。** 対策として、改行を取り除く、`;` を取り除くといった対策が求められます。また、できるだけユーザーの入力を直接MPVに渡さないことも重要になります。

利用可能なコマンド一覧はこちらにあります。
https://mpv.io/manual/master/#list-of-input-commands



## 送信ビットレートを指定できるようにする
Discordへ音声送信を行う際、Opusエンコーダーを通しています。
AudioSourceではPCMデータをそのままライブラリに渡すこともできますが、その場合はビットレートが128kbpsに固定されます。
本当ならOpusで128kbpsは十分な音質なのですが、Discordでは通話用にパケット損失をある程度許容するエンコード設定になっているため音質が落ちます。128kbpsと同じ音質を保つにはビットレートを上げる必要があります。そのため、自分でエンコードできるようにします。
クラス作成時にビットレートを指定します。
`__init__()`の引数に手を加えたりして、`read()` にもエンコード処理を追加します。
```python
def __init__(self, source: Union[str, io.BufferedIOBase], opus_bitrate: int = 128, executable_path:str = '/usr/bin/mpv'):
    # (途中省略)
    # Opusエンコーダー
    self._opus_encoder = OpusEncoder()
    self._opus_encoder.set_bitrate(opus_bitrate)
    self._is_opus = True

def is_opus(self):
    return self._is_opus

```

```python
def read(self) -> bytes:
    frame = self._parecord_process.stdout.read(OpusEncoder.FRAME_SIZE)
        
    if len(frame) != OpusEncoder.FRAME_SIZE:
        return b''
        
    encoded_bytes = self._opus_encoder.encode(frame, OpusEncoder.SAMPLES_PER_FRAME)

    return encoded_bytes
```

## 音量を変えられるようにする
MPVに指示を出して音量を変える方法を探していたのですが、どうやらないようです。
代わりにPulseAudio側で音量を変えるようにしました。
音量は65536段階ありますが、デシベル換算するか百分率で指定するほうが使いやすそうです。

```python
def __init__(self, source: Union[str, io.BufferedIOBase], opus_bitrate: int = 128, executable_path:str = '/usr/bin/mpv'):
    # (途中省略)
    self._pa_sink_volume: int = 65535

def set_volume(self, volume: int):
    self._pa_sink_volume = volume
    if self._pa_sink_volume < 0:
       self._pa_sink_volume = 0
    elif self._pa_sink_volume > 65535:
       self._pa_sink_volume = 65535
    self._run_shell(['pactl', 'set-sink-volume', self.pa_sink_name, str(volume)])

def get_volume(self):
    return self._pa_sink_volume

```

## クリーンアップ処理を追加する
再生終了後はMPVやparecordを確実に止めて、PulseAudioから仮想デバイスを削除する必要があります。

```python
def cleanup(self):
    del self._opus_encoder
    self._mpv_process.kill()
    self._parecord_process.kill()
    self._run_shell(['pactl', 'unload-module', str(self._pa_module_id)])
```


# 使ってみる
一通り完成したのでテスト用のボットに組み込んでみました。
DiscordのインタラクションUIも活用してコントローラーも作りました。シークなどは、先程実装した`send_cmd()`を活用しています。

実演動画 (13.59MiB)
https://s3.arkjp.net/misskey/d2de9bf2-125a-4776-8bff-be81490ef6c2.mp4

# さいごに
MPVとPulseAudioを組み合わせて高度な機能を持ったDiscord音楽ボットを作ることができました。
MPVは再生途中にフィルタの挿入が可能なので、例えば逆再生をしたり、イコライザをかけることも可能になります。
さらにPulseAudioを使っているので、音楽を流しながら読み上げ・・・というミキシングも簡単に実現できます。

ここで紹介したコードと実験用ボットはMITライセンスのもとGitHubで公開していますので、お役にたてればと思います。
https://github.com/CyberRex0/mpv-dpy

Discord.jsや、その他言語のDiscordラッパー向けに移植したものもお待ちしています！
※記事で紹介させていただくこともあります