現存するDiscordの音楽ボットには、一時停止やリピートができるもの、音量を変更できるものがたくさん存在します。
しかしシークができたり再生速度を変えられるというボットは今まで聞いたことがありません。
ならば作ってみようということで、まずはどういう仕組みにするか考えることにしました。
この記事ではDiscord.pyを使用します。
OSはUbuntu 20.04 LTSを使用します。
仕組みを考える
そもそも単体で完結させるのはちょっと厳しい
Discord.pyやDiscord.jsは音声を送信する過程(音声ファイルのデコード等)にFFmpegを使用しています。
ここはどうしても外部に頼らざるを得ない部分でもあります。
さらにここに、今目標としているシーク機能や再生速度を変える機能を実装するとなると、時間がかかって効率が悪くなってしまいます。既存のソフトウェアをうまく組み合わせたほうが早く実現できそうです。
既存のソフトウェアを使う
DiscordボットはGUIのある環境で動かすことはほぼありません。そのため、コンソールしか使えない環境でも動かせる再生ソフトを探していきます。
そこで真っ先に浮かんだのはMPVという再生ソフトです。もちろんコンソールで動きます。
MPVは以前からよく使っていてヘルプも一通り見ていました。MPVはプロセス間通信(IPC)を使ってプログラムから再生状態や再生速度を制御することができます。さらに、FFmpegベースのプレイヤーであるため、多くのコーデックをサポートしています。今回はこれを使うことにしました。
MPVはUbuntuの標準リポジトリにあるのでインストールします。
sudo apt install mpv
設計
MPVは出力方法としてPCMデータを直接出力するというものがあります。 (--ao=pcm
)
出力先をstdoutにすれば、一時ファイルを用意しなくても直接受け取れます。
プログラム側では、出力されたPCMデータを受け取ってエンコードしてDiscordに送信するという仕組みになります。
Discord.pyでは音声送信の元になるものをAudioSourceと呼んでおり、基底クラスが用意されています。(デフォルトで用意されているFFmpegを経由するソースもこの基底クラスを継承しています)
これを継承してMPVSource
というクラスを作成しました。
AudioSourceのライフサイクルは次のとおりです。
__init__()
が呼ばれクラスが作成される(ここで再生の準備等を行う)read()
が呼ばれるので20ms分の音声データを返す (空のバイト列を返すことで再生が終了したことを示す)- 再生終了後
cleanup()
が呼ばれるので、ここでデータの破棄等を行う
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はそこに音声を流してボット側では録音して受け取るという方法を使うことにしました。
ほぼ力技ですね。
まずPulseAudioをインストールします。
sudo apt install pulseaudio
PulseAudioは一定時間接続がないと止まってしまうので設定を変えて止まらないようにします。
# (途中省略)
exit-idle-time = -1
それからPulseAudioを起動します。
pulseaudio --start
仮想デバイスを作成する
PulseAudioではNull SinkというVB-Audio Cableのような仮想デバイスが用意されています。いくらでも作れます。Sinkは直訳で「台所の流し」を意味し、意味通り音声を出力するものを指します。
端末でpactl load-module module-null-sink sink_name=DummySink1
を実行するとDummySink1
という仮想デバイスが作られます。
このコマンドをボットから実行すればよさそうです。
シンクの名前はdiscord-[UUIDv4のID]
にします。
__init__()
の中で仮想デバイスを作成します。
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()
で受け取る処理を追加します。
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)
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を起動します。
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()
を用意します。
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
を入れないと受け付けてくれません。
注意:改行があったり、;
があると1コマンドと認識されて複数実行できるため、ユーザーの入力を適切に処理しないと意図せず他のコマンドが実行され、ホストOSが操作されるおそれがあります。 対策として、改行を取り除く、;
を取り除くといった対策が求められます。また、できるだけユーザーの入力を直接MPVに渡さないことも重要になります。
利用可能なコマンド一覧はこちらにあります。
https://mpv.io/manual/master/#list-of-input-commands
送信ビットレートを指定できるようにする
Discordへ音声送信を行う際、Opusエンコーダーを通しています。
AudioSourceではPCMデータをそのままライブラリに渡すこともできますが、その場合はビットレートが128kbpsに固定されます。
本当ならOpusで128kbpsは十分な音質なのですが、Discordでは通話用にパケット損失をある程度許容するエンコード設定になっているため音質が落ちます。128kbpsと同じ音質を保つにはビットレートを上げる必要があります。そのため、自分でエンコードできるようにします。
クラス作成時にビットレートを指定します。
__init__()
の引数に手を加えたりして、read()
にもエンコード処理を追加します。
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
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段階ありますが、デシベル換算するか百分率で指定するほうが使いやすそうです。
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から仮想デバイスを削除する必要があります。
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ラッパー向けに移植したものもお待ちしています!
※記事で紹介させていただくこともあります