【Discord.js V14】VC１からVC２に音声をStreaming（中継）するBOTを作成する。

皆さん初めまして。Morichanと申します。
下記の※の記述を理解したうえで、記事を読んでください。

※DiscordBot開発初心者の書いた記事です。
（また、プログラミング自体、２年ほどのブランクあり）

[BOTでVCに音楽を流す機能](https://qiita.com/Mori-chan/items/84622c733dc43c419dc5)や[BOTでVCを録音する機能](https://qiita.com/Mori-chan/items/8fcb1770c4d0020a8284)までの流れに関しては、別の記事にまとめてあります。

関連記事：
* [【Windows11】Discord BOTを開発するための開発環境を整える。](https://qiita.com/Mori-chan/items/add6f63b04f70ff53732)
* [【Discord.js V14】Discord BOTにスラッシュコマンドを実装してみる](https://qiita.com/Mori-chan/items/2e869c0e2060b78f0380)
* [【Discord.js V14】Discord BOTをVCに参加させてみる](https://qiita.com/Mori-chan/items/2df4ad15adc79cf2a167)
* [【Discord.js V14】２つのBOTを別々のVCに参加させてみる](https://qiita.com/Mori-chan/items/7d598933753d0abe1b91)
* [【Discord.js V14】スラッシュコマンドのオプションで、既に選択済みの選択肢を２つ目の引数のリストから消す。](https://qiita.com/Mori-chan/items/89ad3245443f1e8ace07)
* [【Discord.js V14】Discord BOTにVCに音楽を流す機能を実装してみる](https://qiita.com/Mori-chan/items/84622c733dc43c419dc5)
* [【Discord.js V14】Discord BOTにVCから１人分の音声を取得して、録音する機能を実装してみる](https://qiita.com/Mori-chan/items/8fcb1770c4d0020a8284)

## 目次
1. [開発環境](#開発環境)
2. [今回このBOTを作るに至った経緯](#今回このbotを作るに至った経緯)
3. [完成イメージ](#完成イメージ)
4. [VCから１人分の音声を取得して、別のVCに流してみる](#vcから１人分の音声を取得して別のvcに流してみる)
5. [取得した音声をPCM形式にデコードして、別のVCに流してみる](#取得した音声をpcm形式にデコードして別のvcに流してみる)
6. [複数人の音声を取得して、audio-mixerでミキシング後、別のVCに流してみる](#複数人の音声を取得してaudio-mixerでミキシング後別のvcに流してみる)
7. [最後に](#最後に)
8. [関連記事](#関連記事)


## 開発環境
OS (Windows11)
discord.js (14.7.1)
node.js (18.13.0)
npm (8.19.3)
@discordjs/voice (^0.14.0)



## 今回このBOTを作るに至った経緯
最近、友達とAmong Usをやる機会があり、
「会議の音声を霊界チャットに中継して聞くことができれば…」
と考え、検索してみたところ、Qiitaで以下の記事を発見。
- [suzukeyさんの記事](https://qiita.com/suzukey/items/87e075f43da2a714fe71)
- [shoshoiさんの記事](https://qiita.com/shoshoi/items/dec1907740148225f185)

一応２つの記事で作成されているBOTを自分の環境に入れてみたところ、
shoshoiさんの[DiscordVoiceTransfer.ts](https://github.com/jirno7pr/DiscordVoiceTransfer.ts)に関しては、環境構築で挫折し、断念。
suzukeyさんの[discord_transfer](https://github.com/suzukey/discord_transfer)に関しては、動きはするものの、BOTがコマンドを認識してくれず、断念。

よって、数日の間暇だったこともあり、自分で開発してみることに。



【参考にしたBOT】
suzukeyさんの<a href='https://github.com/suzukey/discord_transfer'>discord_transfer<a/>
shoshoiさんの<a href='https://github.com/jirno7pr/DiscordVoiceTransfer.ts'>DiscordVoiceTransfer.ts<a/>




## 完成イメージ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3109638/6b17484e-36ec-a72a-358c-f44a21c32215.png)
※画像は[suzukeyさんの記事](https://qiita.com/suzukey/items/87e075f43da2a714fe71)から引用しています。




## VCから１人分の音声を取得して、別のVCに流してみる
これまで行ってきた、VCに音楽を流す機能、VCの音声を録音する機能をもとにして、
新たに、VCの音声をストリーミングする機能を作っていきます。

* `index.js`のコマンド呼び出し部分を`join.js`と`stream.js`に渡す値が同じになるよう、少し変更。
```
	try {
        // コマンドを実行
		if (interaction.commandName === 'join' || interaction.commandName === 'stream') { // ここに追記
			connection = await command.execute(interaction, client1, client2);
		}
```

* `join.js`をコピーして、`stream.js`を作成。
録音機能(record.js)の音声取得部分のみをコピペ。
音楽再生機能(play.js)の音声再生の部分のみをコピペ。
そして改変。

```
const { SlashCommandBuilder, ChannelType } = require('discord.js');
const { joinVoiceChannel, createAudioPlayer, NoSubscriberBehavior, EndBehaviorType, createAudioResource, StreamType } = require('@discordjs/voice');
module.exports = {
	data: new SlashCommandBuilder()
        // コマンドの名前
		.setName('stream')
        // コマンドの説明文
		.setDescription('VCを中継。')
		// コマンドのオプションを追加
		.addChannelOption((option) =>
			option
				.setName('channel1')
				.setDescription('The channel that Listener-bot join')
				.setRequired(true)
				.addChannelTypes(ChannelType.GuildVoice),
		)
		.addChannelOption((option) =>
			option
				.setName('channel2')
				.setDescription('The channel that Speaker-bot join')
				.setRequired(true)
				.addChannelTypes(ChannelType.GuildVoice),
		),
	async execute(interaction, client1, client2) {
		const voiceChannel1 = interaction.options.getChannel('channel1');
		const voiceChannel2 = interaction.options.getChannel('channel2');
		if (voiceChannel1 && voiceChannel2) {
			if (voiceChannel1 === voiceChannel2) {
				await interaction.reply('同じVCには参加できません🥺');
				return;
			}
			// Listener-botがVCに参加する処理
			const connection1 = joinVoiceChannel({
				// なぜかはわからないが、groupの指定をしないと、先にVCに入っているBOTがVCを移動するだけになってしまうので、記述。
				group: 'listener',
				guildId: interaction.guildId,
				channelId: voiceChannel1.id,
				// どっちのBOTを動かしてあげるかの指定をしてあげる。
				adapterCreator: client1.guilds.cache.get(interaction.guildId).voiceAdapterCreator,
				// VC参加時にマイクミュート、スピーカーミュートにするか否か
				selfMute: true,
				selfDeaf: false,
			});
			// Speaker-botがVCに参加する処理
			const connection2 = joinVoiceChannel({
				group: 'speaker',
				guildId: interaction.guildId,
				channelId: voiceChannel2.id,
				adapterCreator: client2.guilds.cache.get(interaction.guildId).voiceAdapterCreator,
				selfMute: false,
				selfDeaf: true,
			});
			// Listener-botが参加しているVCで誰かが話し出したら実行
			connection1.receiver.speaking.on('start', (userId) => {
				// VCの音声取得機能
				const audio = connection1.receiver.subscribe(userId, {
					end: {
						behavior: EndBehaviorType.AfterSilence,
						// 100だと短過ぎるのか、エラー落ちするため1000msに設定
						duration: 1000,
					},
				});
				// 音声をVCに流す機能
				const player = createAudioPlayer({
					behaviors: {
						// 聞いている人がいなくても音声を中継してくれるように設定
						noSubscriber: NoSubscriberBehavior.play,
					},
				});
				const resource = createAudioResource(audio,
					{
						// VCから取得してきた音声はOpus型なので、Opusに設定
						inputType: StreamType.Opus,
					},
				);
				player.play(resource);
				connection2.subscribe(player);
				
			});
			await interaction.reply('VCを中継します！');
		}
		else {
			await interaction.reply('BOTを参加させるVCを指定してください！');
		}
	},
};
```

* `node deploy-commands.js`、`node index.js`を行い、Discordでコマンド`/stream`を入力、実行。
サブアカウントでSpeaker-botのいるVC、メインアカウントでListener-botのいるVCに入りしゃべると……
（テスト用のサブアカウントは、Discord PTBにて同じPCからログインしています。）
無事VCの音が中継されました！


## 取得した音声をPCM形式にデコードして、別のVCに流してみる
後述のオーディオミキサーを使う際に、
VCから取得してきたままの音声形式（Opus）ではダメなため、PCM形式にデコードします。

* npmを使い、必要なものをインストールする。
`npm i prism-media@^1.3.4 --save`

* `stream.js`の一部を改変、追記する。
```
const { SlashCommandBuilder, ChannelType } = require('discord.js');
const { joinVoiceChannel, createAudioPlayer, NoSubscriberBehavior, EndBehaviorType, createAudioResource, StreamType } = require('@discordjs/voice');
const Prism = require('prism-media');
const { PassThrough } = require('stream');

---（以下略）---

				// VCの音声取得機能
				const audio = connection1.receiver.subscribe(userId, {
					end: {
						behavior: EndBehaviorType.AfterSilence,
						// Opusの場合、100msだと短過ぎるのか、エラー落ちするため1000msに設定
						// Rawに変換する場合、1000msだと長過ぎるのか、エラー落ちするため100msに設定
						duration: 100,
					},
				});
				const rawStream = new PassThrough();
				audio
					.pipe(new Prism.opus.Decoder({ rate: 48000, channels: 2, frameSize: 960 }))
					.pipe(rawStream);
				// 音声をVCに流す機能
				const player = createAudioPlayer({
					behaviors: {
						// 聞いている人がいなくても音声を中継してくれるように設定
						noSubscriber: NoSubscriberBehavior.play,
					},
				});
				const resource = createAudioResource(rawStream,
					{
						// VCから取得してきた音声はOpus型なので、Opusに設定
						inputType: StreamType.Raw,
					},
---（以下略）---
```

* `node index.js`を行い、Discordでコマンド`/stream`を入力、実行。
サブアカウントでSpeaker-botのいるVC、メインアカウントでListener-botのいるVCに入りしゃべると……
無事VCの音が中継されました！
Rawの時の`duration: 100,`と、Opusの時の`duration: 1000,`で設定秒数が違うのはなぜ…？
これのせいで、めちゃくちゃ時間食った……


## 複数人の音声を取得して、audio-mixerでミキシング後、別のVCに流してみる
現状では、一人が話しているだけであれば、きれいに音声を中継できるものの、
複数人が同時に話すと、音声がぷつぷつになってしまい、何を言っているのか聞き取れません。
なので、複数人が同時に話した時、それぞれの音をオーディオミキサーを使い、合成して１つの音として出力します。

* npmを使い、必要なものをインストールする。
`npm i audio-mixer --save`

* `stream.js`にaudio-mixerの記述を追加していく。
……のだが、[公式のReadme](https://www.npmjs.com/package/audio-mixer?activeTab=readme#code-example)がよくわからな過ぎたため、
ここで冒頭で参考にしたQiita記事、[shoshoiさんの記事](https://qiita.com/shoshoi/items/dec1907740148225f185)のコードを参考に、記述を追加しました。
これが最終的なコードです。
```
const { SlashCommandBuilder, ChannelType } = require('discord.js');
const { joinVoiceChannel, createAudioPlayer, NoSubscriberBehavior, EndBehaviorType, createAudioResource, StreamType } = require('@discordjs/voice');
const AudioMixer = require('audio-mixer');
const Prism = require('prism-media');
const { PassThrough } = require('stream');

module.exports = {
	data: new SlashCommandBuilder()
        // コマンドの名前
		.setName('stream')
        // コマンドの説明文
		.setDescription('VCを中継。')
		// コマンドのオプションを追加
		.addChannelOption((option) =>
			option
				.setName('channel1')
				.setDescription('The channel that Listener-bot join')
				.setRequired(true)
				.addChannelTypes(ChannelType.GuildVoice),
		)
		.addChannelOption((option) =>
			option
				.setName('channel2')
				.setDescription('The channel that Speaker-bot join')
				.setRequired(true)
				.addChannelTypes(ChannelType.GuildVoice),
		),
	async execute(interaction, client1, client2) {
		const voiceChannel1 = interaction.options.getChannel('channel1');
		const voiceChannel2 = interaction.options.getChannel('channel2');
		if (voiceChannel1 && voiceChannel2) {
			if (voiceChannel1 === voiceChannel2) {
				await interaction.reply('同じVCには参加できません🥺');
				return;
			}
			// Listener-botがVCに参加する処理
			const connection1 = joinVoiceChannel({
				// なぜかはわからないが、groupの指定をしないと、先にVCに入っているBOTがVCを移動するだけになってしまうので、記述。
				group: 'listener',
				guildId: interaction.guildId,
				channelId: voiceChannel1.id,
				// どっちのBOTを動かしてあげるかの指定をしてあげる。
				adapterCreator: client1.guilds.cache.get(interaction.guildId).voiceAdapterCreator,
				// VC参加時にマイクミュート、スピーカーミュートにするか否か
				selfMute: true,
				selfDeaf: false,
			});
			// Speaker-botがVCに参加する処理
			const connection2 = joinVoiceChannel({
				group: 'speaker',
				guildId: interaction.guildId,
				channelId: voiceChannel2.id,
				adapterCreator: client2.guilds.cache.get(interaction.guildId).voiceAdapterCreator,
				selfMute: false,
				selfDeaf: true,
			});
			const mixer = new AudioMixer.Mixer({
				channels: 2,
				bitDepth: 16,
				sampleRate: 48000,
				clearInterval: 250,
			});
			// Listener-botが参加しているVCで誰かが話し出したら実行
			connection1.receiver.speaking.on('start', (userId) => {
				const standaloneInput = new AudioMixer.Input({
					channels: 2,
					bitDepth: 16,
					sampleRate: 48000,
					volume: 100,
				});
				const audioMixer = mixer;
				audioMixer.addInput(standaloneInput);
				// VCの音声取得機能
				const audio = connection1.receiver.subscribe(userId, {
					end: {
						behavior: EndBehaviorType.AfterSilence,
						// Opusの場合、100msだと短過ぎるのか、エラー落ちするため1000msに設定
						// Rawに変換する場合、1000msだと長過ぎるのか、エラー落ちするため100msに設定
						duration: 100,
					},
				});
				const rawStream = new PassThrough();
				audio
					.pipe(new Prism.opus.Decoder({ rate: 48000, channels: 2, frameSize: 960 }))
					.pipe(rawStream);
				const p = rawStream.pipe(standaloneInput);
				// 音声をVCに流す機能
				const player = createAudioPlayer({
					behaviors: {
						// 聞いている人がいなくても音声を中継してくれるように設定
						noSubscriber: NoSubscriberBehavior.play,
					},
				});
				const resource = createAudioResource(mixer,
					{
						// VCから取得してきた音声はOpus型なので、Opusに設定
						inputType: StreamType.Raw,
					},
				);
				player.play(resource);
				connection2.subscribe(player);
				rawStream.on('end', () => {
					if (this.audioMixer != null) {
						this.audioMixer.removeInput(standaloneInput);
						standaloneInput.destroy();
						rawStream.destroy();
						p.destroy();
					}
				});
			});
			await interaction.reply('VCを中継します！');
		}
		else {
			await interaction.reply('BOTを参加させるVCを指定してください！');
		}
	},
};
```
* `node index.js`を行い、Discordでコマンド`/stream`を入力、実行。
複数人でお話をしてみると、ぷつぷつ途切れずに、音がしっかりと重なって聞こえます。
[GitHub](https://github.com/Mori-Chan/stream-bot/tree/main)にコードを載せてあります。




## 最後に
暇だから何となく欲しいものを作ろうと思いやってみたのですが、ソースがほぼなく、動き出してから４日間もかかってしまいました…
AngularやSocket.IO、Dockerの勉強時よりは幾分かましでしたが、[Discord.js Japan User Group](https://discord.gg/8yNx2Fs)というDiscordサーバーで有識者の方に質問をできていなければ、倍以上の時間がかかっていたと思います。
質問に答えてくださった方々には感謝しかありません。

今回、初めてDiscordBotに触れて、Botのコマンドの打ち方もわからない中、開発していくうえで、いろいろと学びを得て成長していく過程は超絶楽しかったですｗ
この記事を見ている方も、ぜひDiscordBot開発を存分に楽しんでください。
[GitHub](https://github.com/Mori-Chan/stream-bot/tree/main)


## 関連記事
* [【Windows11】Discord BOTを開発するための開発環境を整える。](https://qiita.com/Mori-chan/items/add6f63b04f70ff53732)
* [【Discord.js V14】Discord BOTにスラッシュコマンドを実装してみる](https://qiita.com/Mori-chan/items/2e869c0e2060b78f0380)
* [【Discord.js V14】Discord BOTをVCに参加させてみる](https://qiita.com/Mori-chan/items/2df4ad15adc79cf2a167)
* [【Discord.js V14】２つのBOTを別々のVCに参加させてみる](https://qiita.com/Mori-chan/items/7d598933753d0abe1b91)
* [【Discord.js V14】スラッシュコマンドのオプションで、既に選択済みの選択肢を２つ目の引数のリストから消す。](https://qiita.com/Mori-chan/items/89ad3245443f1e8ace07)
* [【Discord.js V14】Discord BOTにVCに音楽を流す機能を実装してみる](https://qiita.com/Mori-chan/items/84622c733dc43c419dc5)
* [【Discord.js V14】Discord BOTにVCから１人分の音声を取得して、録音する機能を実装してみる](https://qiita.com/Mori-chan/items/8fcb1770c4d0020a8284)
* 【Discord.js V14】VC１からVC２に音声をStreaming（中継）するBOTを作成する。
* [【Discord.js V14】取得するVCの音声をロールによって判別する](https://qiita.com/Mori-chan/items/41cd914bb9a93d930bff)