Javaで実装する音声処理機能のすべて：Discordボット開発【その②】

こんにちは。この記事は[**Spring BootとJDAを連携させる：Discordボット開発【その①】**](https://qiita.com/blue_islands/items/854ede846196537918af)の続きの記事になります。

Discordは、ゲームコミュニティだけでなく、様々なオンラインコミュニティで広く使用されています。Discordボットは、これらのコミュニティを活性化し、メンバー間のやり取りをより豊かにするための重要なツールとなっています。特に、音声対話機能を備えたボットは、ユーザー体験を大幅に向上させることができます。本記事は、Javaを使用してDiscordボットに音声処理機能を実装したい開発者向けに書かれています。

# 1. なぜ音声処理機能が重要なのか

音声対話は、ユーザーが直感的にボットとやり取りできるため、コミュニケーションをより自然で親密なものにします。音声コマンドの受け取りや、音声による返答を行うボットは、情報提供やエンターテイメント、さらには教育目的での使用においても、その可能性を大いに広げることができます。

# 2. 主要なコンポーネント

JavaでDiscordボットの音声処理機能を実装する際には、以下の5つの主要なコンポーネントが必要です。
注: CustomInputStreamSourceManager クラスに関しては、次のブログ記事で詳しく解説します。詳細については[**こちら**](https://qiita.com/blue_islands/items/3a4dcf1d281d97f20e87)をご覧ください。

a. **NobyBotクラス**: メッセージの受信と基本的なコマンド処理を担当します。

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

import java.io.IOException;
import java.util.concurrent.ExecutionException;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager;
import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager;
import com.sedmelluq.discord.lavaplayer.source.AudioSourceManagers;

import jp.livlog.cotogoto.api.discord.source.CustomInputStreamSourceManager;
import lombok.extern.slf4j.Slf4j;
import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;

@Service
@Slf4j
public class NobyBot extends ListenerAdapter {

    @Value ("${google.projectId}")
    private String googleProjectId;

    @Override
    public void onMessageReceived(final MessageReceivedEvent event) {

        try {
            if (event.getAuthor().isBot()) {
                return; // Ignore messages from bots
            }

            this.printMessage(event);

            final var message = event.getMessage().getContentDisplay();
            if (message.startsWith("!")) {
                this.handleCommand(event, message);
            } else {
                this.echoMessage(event, message);
            }
        } catch (final Exception e) {
            NobyBot.log.error(e.getMessage(), e);
        }

    }


    private void printMessage(final MessageReceivedEvent event) {

        if (event.isFromType(ChannelType.PRIVATE)) {
            NobyBot.log.info("[PM] {}: {}", event.getAuthor().getName(), event.getMessage().getContentDisplay());
        } else {
            NobyBot.log.info("[{}][{}] {}: {}", event.getGuild().getName(), event.getChannel().getName(),
                    event.getMember().getEffectiveName(), event.getMessage().getContentDisplay());
        }
    }


    private void handleCommand(final MessageReceivedEvent event, final String message) throws ExecutionException, InterruptedException, IOException {

        final var command = message.split(" ")[0].substring(1).toLowerCase();
        switch (command) {
            case "join":
                this.joinVoiceChannel(event);
                break;
            case "leave":
                this.leaveVoiceChannel(event.getGuild());
                break;
            // Add more commands as needed
        }
    }


    private void echoMessage(final MessageReceivedEvent event, final String message) {

        event.getChannel().sendMessage(message).queue();
    }


    private void joinVoiceChannel(final MessageReceivedEvent event) throws ExecutionException, InterruptedException, IOException {

        // メッセージ送信者を取得（nullチェック）
        var member = event.getMember();
        if (member == null) {
            // イベントが発生したギルド内でメンバーを取得
            member = event.getGuild().getMemberById(event.getAuthor().getId());
        }

        if (member != null) {
            final var voiceState = member.getVoiceState();
            if (voiceState != null) {
                final var voiceChannel = voiceState.getChannel(); // 現在の音声チャンネルを取得
                if (voiceChannel != null) { // ユーザーが音声チャンネルにいる場合
                    final var audioManager = voiceChannel.getGuild().getAudioManager();

                    final var audioProcessor = new AudioProcessor();
                    final var sharedAudioData = new SharedAudioData();
                    final var scheduler = new DataCheckScheduler(sharedAudioData);
                    scheduler.start();

                    final AudioPlayerManager playerManager = new DefaultAudioPlayerManager();
                    playerManager.registerSourceManager(new CustomInputStreamSourceManager());
                    AudioSourceManagers.registerLocalSource(playerManager);

                    final var nobyHandler = new NobyAudioHandler(audioProcessor, sharedAudioData, playerManager);
                    audioManager.setReceivingHandler(nobyHandler); // NobyHandlerを設定
                    audioManager.setSendingHandler(nobyHandler); // NobyHandlerを設定
                    audioManager.openAudioConnection(voiceChannel); // そのチャンネルに接続
                    return;
                }
            }
        }

        // メッセージを送信したチャンネルを取得
        final var channel = event.getChannel();
        channel.sendMessage("ボイスチャンネルに誰もいません。").queue();
    }


    private void leaveVoiceChannel(final net.dv8tion.jda.api.entities.Guild guild) {

        final var audioManager = guild.getAudioManager();
        if (audioManager.isConnected()) {
            audioManager.closeAudioConnection();
        }
    }

}
```

b. **NobyAudioHandlerクラス**: 音声データの送受信を管理します。

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

import java.nio.ByteBuffer;
import java.util.Base64;

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;

@Slf4j
public class NobyAudioHandler implements AudioReceiveHandler, AudioSendHandler {

    private final AudioProcessor     audioProcessor;

    private final SharedAudioData    sharedAudioData;

    private final AudioPlayerManager playerManager;

    private final AudioPlayer        audioPlayer;

    private AudioFrame               lastFrame;

    public NobyAudioHandler(final AudioProcessor audioProcessor, final SharedAudioData sharedAudioData, final AudioPlayerManager playerManager) {

        this.audioProcessor = audioProcessor;
        this.sharedAudioData = sharedAudioData;
        this.playerManager = playerManager;
        this.audioPlayer = playerManager.createPlayer();
    }


    @Override
    public boolean canReceiveCombined() {

        return false; // Combined audio is not received
    }


    @Override
    public boolean canReceiveUser() {

        return true; // User audio is received
    }


    @Override
    public void handleUserAudio(final UserAudio userAudio) {

        final var receiveData = userAudio.getAudioData(1.0); // Get audio data as byte array
        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());
                final var base64String = Base64.getEncoder().encodeToString(replyData);
                this.loadAndPlayTrack(base64String);
            }
        } catch (final Exception e) {
            NobyAudioHandler.log.error(e.getMessage(), e);
        }
        this.lastFrame = this.audioPlayer.provide();
        return this.lastFrame != null;
    }


    private void loadAndPlayTrack(final String trackString) {

        this.playerManager.loadItem(trackString, new AudioLoadResultHandler() {

            @Override
            public void trackLoaded(final AudioTrack track) {

                NobyAudioHandler.this.audioPlayer.playTrack(track); // Play the loaded track
            }


            @Override
            public void playlistLoaded(final AudioPlaylist playlist) {

                // Handle playlist loading
            }


            @Override
            public void noMatches() {

                // Handle no matches found
            }


            @Override
            public void loadFailed(final FriendlyException e) {

                NobyAudioHandler.log.error(e.getMessage(), e);
            }
        });
    }


    @Override
    public ByteBuffer provide20MsAudio() {

        return ByteBuffer.wrap(this.lastFrame.getData());
    }


    @Override
    public boolean isOpus() {

        return true;
    }

    // Additional methods and logic as needed
}
```

c. **AudioProcessorクラス**: 受け取ったPCM形式のオーディオデータをWAV形式に変換します。

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

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;

import javax.sound.sampled.AudioFileFormat;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;

import net.dv8tion.jda.api.audio.AudioSendHandler;

public class AudioProcessor {

    public byte[] processAudio(final String userId, final byte[] audioData) {

        try {
            final var wavData = this.convertPcmToWav(audioData);
            // ここでWAVデータを使って音声認識や合成を行う

            return wavData;
        } catch (final IOException e) {
            e.printStackTrace();
            return null;
        }
    }


    private byte[] convertPcmToWav(final byte[] pcmData) throws IOException {

        try (
                var wavOutputStream = new ByteArrayOutputStream();
                var audioInputStream = new AudioInputStream(
                        new ByteArrayInputStream(pcmData),
                        AudioSendHandler.INPUT_FORMAT,
                        pcmData.length)) {

            AudioSystem.write(audioInputStream, AudioFileFormat.Type.WAVE, wavOutputStream);
            return wavOutputStream.toByteArray();
        }
    }

}

```
    
d. **SharedAudioDataクラス**: 複数のユーザーから受け取った音声データを管理します。

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

import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.CopyOnWriteArrayList;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class SharedAudioData {

    private final Queue <AudioData>           audioQueue         = new ConcurrentLinkedQueue <>();

    private final Map <String, List <byte[]>> accumulatedDataMap = new ConcurrentHashMap <>();

    private final Map <String, Long>          dataStartTimeMap   = new ConcurrentHashMap <>();

    private long                              lastAddTime        = System.currentTimeMillis();

    public void checkAndMoveData() {

        final var currentTime = System.currentTimeMillis();
        for (final Map.Entry <String, List <byte[]>> entry : this.accumulatedDataMap.entrySet()) {
            if (this.shouldMoveData(entry.getKey(), currentTime)) {
                this.moveDataToQueue(entry);
            }
        }
    }


    private boolean shouldMoveData(final String id, final long currentTime) {

        final long startTime = this.dataStartTimeMap.getOrDefault(id, this.lastAddTime);
        final var data = this.accumulatedDataMap.get(id);
        return !data.isEmpty() && ((currentTime - this.lastAddTime) > DiscordSymbol.DURING_CONVERSATION_MILLISECONDS ||
                (currentTime - startTime) > DiscordSymbol.TALK_MILLISECONDS);
    }


    private void moveDataToQueue(final Map.Entry <String, List <byte[]>> entry) {

        final var combinedData = this.combineData(entry.getValue());
        final var audioData = new AudioData(entry.getKey(), combinedData);
        this.audioQueue.add(audioData);
        this.accumulatedDataMap.remove(entry.getKey());
        this.dataStartTimeMap.remove(entry.getKey());
        this.lastAddTime = System.currentTimeMillis();
    }


    public void addAudioData(final String id, final byte[] data) {

        final var accumulatedData = this.accumulatedDataMap.computeIfAbsent(id, k -> {
            this.dataStartTimeMap.put(id, System.currentTimeMillis());
            return new CopyOnWriteArrayList <>();
        });
        accumulatedData.add(data);
        this.lastAddTime = System.currentTimeMillis();
    }


    private byte[] combineData(final List <byte[]> accumulatedData) {

        final var size = accumulatedData.stream().mapToInt(array -> array.length).sum();
        final var decodedData = new byte[size];
        var index = 0;
        for (final byte[] bytes : accumulatedData) {
            System.arraycopy(bytes, 0, decodedData, index, bytes.length);
            index += bytes.length;
        }
        return decodedData;
    }


    public AudioData takeAudioData() {

        return this.audioQueue.poll();
    }

    @Getter
    @Setter
    public class AudioData {

        private final String id;

        private final byte[] data;

        public AudioData(final String id, final byte[] data) {

            this.id = id;
            this.data = data;
        }
    }
}

```

e. **DataCheckSchedulerクラス**: 定期的にSharedAudioData内の音声データをチェックし、処理します。

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

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class DataCheckScheduler {

    private final SharedAudioData          sharedAudioData;

    private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);

    public DataCheckScheduler(final SharedAudioData sharedAudioData) {

        this.sharedAudioData = sharedAudioData;
    }


    public void start() {

        this.scheduler.scheduleAtFixedRate(() -> this.sharedAudioData.checkAndMoveData(),
                0,
                DiscordSymbol.LOOP_MILLISECONDS,
                TimeUnit.MILLISECONDS);
    }
}

```

# 3. Mavenの設定
音声処理機能の実装には、以下のようなMavenの依存関係が必要です。`pom.xml` ファイルに以下を追加してください。

```xml:pom.xml
<dependencies>
    <!-- JDAの依存関係 -->
    <dependency>
        <groupId>net.dv8tion</groupId>
        <artifactId>JDA</artifactId>
        <version>5.0.0-beta.19</version>
    </dependency>
    <!-- LavaPlayerの依存関係 -->
    <dependency>
        <groupId>com.sedmelluq</groupId>
        <artifactId>lavaplayer</artifactId>
        <version>1.3.77</version>
    </dependency>
    <!-- その他必要な依存関係 -->
</dependencies>
```

# 4. 実装のポイント

- **音声データの変換**: 音声認識や音声合成を行う前に、適切なオーディオフォーマットへの変換が必要です。このために、AudioProcessorクラスが重要な役割を果たします。
- **データの管理**: SharedAudioDataクラスを使用して、音声データを効果的に管理します。このクラスは、データの蓄積、結合、および処理のためのキューへの移動を担当します。
- **リアルタイム処理**: NobyAudioHandlerクラスにより、ボットはユーザーの音声をリアルタイムで受信し、応答することが可能になります。

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

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

https://cotogoto.ai/

# 6. 結論

JavaでDiscordボットの音声処理機能を実装することは、コミュニティの活性化やユーザーエンゲージメントの向上に大きく寄与します。この記事で紹介したコンポーネントと実装のポイントを理解し、適用することで、よりインタラクティブで魅力的なボットを開発することができるでしょう。