自動ミュート機能の実装：Discordボット開発【その④】

こんにちは。この記事は[**音声認識機能の導入：Discordボット開発【その③】**](https://qiita.com/blue_islands/items/5015b9d980dca22808ef)の続きの記事になります。

# 1. Discord Botの音声再生管理：自動ミュート機能の実装

Discordサーバーで音楽を再生する際、背景音楽としてのみ使用したい場合があります。そんな時、音楽再生中にユーザーが話すことを防ぐため、ボイスチャンネル内のメンバーを自動的にミュートし、再生が終了したらミュートを解除する機能が役立ちます。本記事では、Javaを使用してDiscord Botにこの機能を実装する方法を紹介します。

# 2. 使用技術

- **Java**: 実装言語
- **JDA (Java Discord API)**: Discord Bot開発用のライブラリ
- **LavaPlayer**: Java用の高機能なオーディオライブラリ

# 3. クラスの概要

`TrackScheduler`クラスは、`AudioEventAdapter`を継承しており、LavaPlayerのイベントリスニング機能を利用して音声トラックの再生状態を監視します。このクラスは、音声トラックの再生開始時と終了時に実行される特定のアクションを定義します。

## 主な機能

- **再生開始時の自動ミュート**: `onTrackStart`メソッドにより、音声トラックの再生が開始されたときにボイスチャンネル内の全メンバーをミュートします。
- **再生終了時のミュート解除**: `onTrackEnd`メソッドにより、トラックの再生が終了したときにミュートを解除します。

## コードの説明

```java:TrackScheduler.java
package jp.livlog.cotogoto.api.discord;

import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.player.event.AudioEventAdapter;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason;

import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;

/**
 * Discordのボイスチャンネルにおける音声トラックの再生状態に基づいて、
 * チャンネル内のメンバーを自動的にミュートまたはミュート解除するクラスです。
 */
public class TrackScheduler extends AudioEventAdapter {

    private final Guild        guild;   // Discordのギルド

    private final VoiceChannel channel; // ボイスチャンネル

    /**
     * TrackSchedulerのコンストラクタ。
     *
     * @param guild   対象のDiscordギルド
     * @param channel 対象のボイスチャンネル
     */
    public TrackScheduler(final Guild guild, final VoiceChannel channel) {

        this.guild = guild;
        this.channel = channel;
    }


    /**
     * 音声トラックの再生が開始されたときに呼び出されます。
     * ボイスチャンネル内のすべてのユーザーをミュートします。
     *
     * @param player 再生を行うAudioPlayer
     * @param track  再生が開始されたAudioTrack
     */
    @Override
    public void onTrackStart(final AudioPlayer player, final AudioTrack track) {

        this.muteAllMembersInChannel(true); // すべてのユーザーをミュート
    }


    /**
     * 音声トラックの再生が終了したときに呼び出されます。
     * ボイスチャンネル内のすべてのユーザーのミュートを解除します。
     *
     * @param player    再生を行っていたAudioPlayer
     * @param track     再生が終了したAudioTrack
     * @param endReason 再生終了の理由
     */
    @Override
    public void onTrackEnd(final AudioPlayer player, final AudioTrack track, final AudioTrackEndReason endReason) {

        if (endReason.mayStartNext) {
            this.muteAllMembersInChannel(false); // ミュート解除
        }
    }


    /**
     * 指定された状態に基づいてボイスチャンネル内のメンバーをミュートまたはミュート解除します。
     * ボットはこの処理の対象外です。
     *
     * @param mute ミュートする場合はtrue、ミュート解除する場合はfalse
     */
    private void muteAllMembersInChannel(final boolean mute) {

        for (final Member member : this.channel.getMembers()) {
            if (!member.getUser().isBot()) { // ボットを除外
                this.guild.mute(member, mute).queue(); // メンバーをミュートまたはミュート解除
            }
        }
    }
}


```

このクラスでは、`AudioPlayer`、`Guild`（サーバー）、および`VoiceChannel`（ボイスチャンネル）のインスタンスを管理します。これにより、特定のボイスチャンネル内での音声再生とユーザーのミュート管理を可能にします。

また、呼び出し元は以下のようになります。

```java:NobyAudioHandler.java
package jp.livlog.cotogoto.api.discord;

import java.nio.ByteBuffer;

import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler;
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager;
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import com.sedmelluq.discord.lavaplayer.track.playback.AudioFrame;

import lombok.extern.slf4j.Slf4j;
import net.dv8tion.jda.api.audio.AudioReceiveHandler;
import net.dv8tion.jda.api.audio.AudioSendHandler;
import net.dv8tion.jda.api.audio.UserAudio;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;

/**
 * NobyAudioHandlerは、Discordのオーディオデータの送受信と、音声再生の管理を行うクラスです。
 * オーディオプレイヤーの作成、オーディオトラックの再生、ユーザー音声の受信処理などを担当します。
 */
@Slf4j
public class NobyAudioHandler implements AudioReceiveHandler, AudioSendHandler {

    private final AudioProcessor     audioProcessor; // オーディオ処理を担当するプロセッサ
    private final SharedAudioData    sharedAudioData; // 共有オーディオデータ
    private final AudioPlayerManager playerManager; // オーディオプレイヤーの管理
    private final AudioPlayer        audioPlayer; // オーディオプレイヤー
    private final Guild              guild; // Discordギルド
    private final VoiceChannel       channel; // ボイスチャンネル
    private AudioFrame               lastFrame; // 最後のオーディオフレーム

    /**
     * NobyAudioHandlerのコンストラクタ。
     * 
     * @param audioProcessor オーディオ処理を担当するプロセッサ
     * @param sharedAudioData 共有オーディオデータ
     * @param playerManager オーディオプレイヤーの管理を行うマネージャ
     * @param guild 対象のDiscordギルド
     * @param channel 対象のボイスチャンネル
     */
    public NobyAudioHandler(
            final AudioProcessor audioProcessor,
            final SharedAudioData sharedAudioData,
            final AudioPlayerManager playerManager,
            final Guild guild,
            final VoiceChannel channel) {
        this.audioProcessor = audioProcessor;
        this.sharedAudioData = sharedAudioData;
        this.playerManager = playerManager;
        this.audioPlayer = playerManager.createPlayer();
        this.guild = guild;
        this.channel = channel;
        this.audioPlayer.addListener(new TrackScheduler(guild, channel));
    }

    @Override
    public boolean canReceiveCombined() {
        // 合成されたオーディオは受け取らないため、falseを返します。
        return false;
    }

    @Override
    public boolean canReceiveUser() {
        // ユーザーオーディオは受け取るため、trueを返します。
        return true;
    }

    @Override
    public void handleUserAudio(final UserAudio userAudio) {
        // ユーザーから受け取ったオーディオデータを処理します。
        final var receiveData = userAudio.getAudioData(1.0); // バイト配列としてオーディオデータを取得
        this.sharedAudioData.addAudioData(userAudio.getUser().getId(), receiveData);
    }

    @Override
    public boolean canProvide() {
        // オ

ーディオデータが提供可能かどうかを判定します。
        try {
            final var audioData = this.sharedAudioData.takeAudioData();
            if (audioData != null) {
                final var replyData = this.audioProcessor.processAudio(audioData.getId(), audioData.getData());
                if (replyData != null) {
                    final var base64String = DataUriSchemeGenerator.encodeToBase64WithMimeType(replyData);
                    this.loadAndPlayTrack(base64String);
                }
            }
        } catch (final Exception e) {
            NobyAudioHandler.log.error(e.getMessage(), e);
        }
        this.lastFrame = this.audioPlayer.provide();
        return this.lastFrame != null;
    }

    /**
     * 指定されたトラックをロードし、再生を開始します。
     * 
     * @param trackString 再生するトラックのURLまたは識別子
     */
    private void loadAndPlayTrack(final String trackString) {
        this.playerManager.loadItem(trackString, new AudioLoadResultHandler() {

            @Override
            public void trackLoaded(final AudioTrack track) {
                // ロードされたトラックを再生します。
                NobyAudioHandler.this.audioPlayer.playTrack(track);
            }

            @Override
            public void playlistLoaded(final AudioPlaylist playlist) {
                // プレイリストがロードされた場合の処理をここに実装します。
            }

            @Override
            public void noMatches() {
                // トラックが見つからない場合の処理をここに実装します。
            }

            @Override
            public void loadFailed(final FriendlyException e) {
                // トラックのロードに失敗した場合のエラー処理をここに実装します。
                NobyAudioHandler.log.error(e.getMessage(), e);
            }
        });
    }

    @Override
    public ByteBuffer provide20MsAudio() {
        // 20ミリ秒分のオーディオデータを提供します。
        return ByteBuffer.wrap(this.lastFrame.getData());
    }

    @Override
    public boolean isOpus() {
        // 提供されるオーディオデータがOpus形式であるかどうかを返します。
        return true;
    }
}
```

`NobyAudioHandler`クラスは以前までの実装を省略して書いていますが、全体を通すことで、Botのプログラムとして仕上がってきます。


## ミュートとミュート解除の実装

`muteAllMembersInChannel`メソッドは、ボイスチャンネル内の全メンバーに対してミュートまたはミュート解除を行います。この処理は、ボット以外のユーザーに対して適用されます。

# 4. 実装のポイント

この機能を実装する際には、次の点に注意してください。

- **ユーザーの権限**: Botがミュートを行うためには、適切な権限が必要です。
- **ユーザーエクスペリエンス**: 自動ミュート機能を適用する前に、ユーザーに通知することで、混乱を避けることができます。

# 5. CotoGotoとの連携について
最後に、この技術メモは、CotoGotoの機能拡張の一環としてDiscordを連携するためのものです。CotoGoto（コトゴト）は、人工知能を搭載した会話型アプリで、日常的な会話を通じて作業内容を分析し、タスク管理やスケジュール管理をサポートします。Discordボットの導入により、CotoGotoはより多くのユーザーとのインタラクションを実現し、日々の生活や作業に役立つ情報を提供できるようになります。

詳細は、以下をご覧ください。

https://cotogoto.ai/

# 6. まとめ

`TrackScheduler`クラスを使用して、Discord Botに音声再生中の自動ミュート機能を実装する方法を紹介しました。この機能は、音楽をバックグラウンドで再生する際など、さまざまなシナリオで役立つでしょう。実装に当たっては、適切な権限の設定とユーザーへの明確なコミュニケーションが重要です。