Discordボットのためのリアルタイム音声処理：LavaPlayerでInputStreamに対応する

こんにちは。この記事は[**Discordボット開発：Javaで実装する音声処理機能のすべて【その②】**](https://qiita.com/blue_islands/items/3667d1740f6018e59695)の**CustomInputStreamSourceManager クラス**についての解説になります。

# 1. はじめに
Discordボット開発者の皆さん、リアルタイムでの音声合成と再生に悩まされたことはありませんか？今回は、その課題を解決するためのLavaPlayerのカスタム`InputStream`について解説します。

# 2. 目的
この記事は、Discordボット開発者や音声合成に興味のある方々に向けて、LavaPlayerでのリアルタイム音声合成と再生の方法を紹介します。

# 3. LavaPlayerとは？

LavaPlayerは、Javaで書かれた強力で柔軟なオーディオプレイヤーライブラリです。主にDiscordボットの開発で使用され、YouTubeやSoundCloudなどのオンラインソースからの音声再生、ローカルファイルの再生、さらにはカスタムオーディオソースの実装をサポートしています。

### 特徴
- **豊富なフォーマットサポート**: LavaPlayerは、様々なオーディオフォーマットとストリーミングソースに対応しています。
- **カスタマイズ性**: カスタムソースマネージャを通じて、独自のオーディオソースやデータ処理方法を統合することが可能です。
- **高性能**: パフォーマンスに最適化された設計により、リソースを効率的に利用しながら高品質なオーディオ再生を提供します。
- **広範囲な使用**: Discordボット開発者に広く受け入れられており、コミュニティによるサポートと拡張が活発です。

### Discordボット開発での重要性
Discordボットにおいて、オーディオ再生は重要な機能の一つです。LavaPlayerは、このニーズに応えるために特化しており、ボット開発者が簡単に高品質なオーディオ機能を統合できるようにしています。カスタム`InputStream`のサポートを通じて、LavaPlayerはリアルタイム音声合成と再生のような高度なオーディオ処理も可能にします。

# 4. 解決したいこと
LavaPlayerは素晴らしいライブラリですが、デフォルトではリアルタイムの`InputStream`からの音声再生に対応していません。ここで、カスタム`InputStream`の実装方法を解説し、その問題を解決します。

# 5. カスタムクラスの詳細な解説

### CustomInputStreamSourceManager
`CustomInputStreamSourceManager` は、LavaPlayerに新しいオーディオソースタイプとしてカスタム `InputStream` を追加するためのソースマネージャです。このクラスは `AudioSourceManager` を拡張し、LavaPlayerがカスタム `InputStream` からオーディオデータを読み込んで再生するための機能を提供します。これにより、開発者はさまざまな音声ソース（例えば、オンラインの音声合成サービスや、特定のAPIからのストリーム）からデータを取得し、直接Discordで再生することができます。

```java
package jp.livlog.cotogoto.api.discord.source;

import java.io.DataInput;
import java.io.DataOutput;
import java.io.IOException;

import com.sedmelluq.discord.lavaplayer.container.MediaContainerDescriptor;
import com.sedmelluq.discord.lavaplayer.container.MediaContainerRegistry;
import com.sedmelluq.discord.lavaplayer.container.wav.WavContainerProbe;
import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager;
import com.sedmelluq.discord.lavaplayer.source.ProbingAudioSourceManager;
import com.sedmelluq.discord.lavaplayer.track.AudioItem;
import com.sedmelluq.discord.lavaplayer.track.AudioReference;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo;

public class CustomInputStreamSourceManager extends ProbingAudioSourceManager {

    public CustomInputStreamSourceManager() {

        super(MediaContainerRegistry.DEFAULT_REGISTRY);
    }


    @Override
    public String getSourceName() {

        return "CustomInputStream";
    }


    @Override
    public AudioItem loadItem(final AudioPlayerManager manager, final AudioReference reference) {

        try {
            // InputStreamの取得とAudioTrackの作成に必要なロジックを実装
            final var title = reference.getTitle();
            final var author = reference.getAuthor();
            final var length = 0L;
            final var identifier = reference.getIdentifier();
            final var isStream = false;
            final var uri = reference.getUri();

            final var trackInfo = new AudioTrackInfo(title, author, length, identifier, isStream, uri);
            final var containerDescriptor = new MediaContainerDescriptor(new WavContainerProbe(), null);

            return this.createTrack(trackInfo, containerDescriptor);
        } catch (final Exception e) {
            // エラーハンドリングのロジック
            return null;
        }
    }


    @Override
    public boolean isTrackEncodable(final AudioTrack track) {

        return true;
    }


    @Override
    public void encodeTrack(final AudioTrack track, final DataOutput output) throws IOException {

        this.encodeTrackFactory(((CustomInputStreamAudioTrack) track).getContainerTrackFactory(), output);
    }


    @Override
    public AudioTrack decodeTrack(final AudioTrackInfo trackInfo, final DataInput input) throws IOException {

        final var containerTrackFactory = this.decodeTrackFactory(input);
        if (containerTrackFactory != null) {
            return this.createTrack(trackInfo, containerTrackFactory);
        }
        return null;
    }


    @Override
    public void shutdown() {

        // 終了時の処理が不要な場合
    }


    @Override
    protected AudioTrack createTrack(final AudioTrackInfo trackInfo, final MediaContainerDescriptor containerTrackFactory) {

        return new CustomInputStreamAudioTrack(trackInfo, containerTrackFactory, this);
    }
}
```

### CustomInputStreamAudioTrack
このクラスは、オーディオトラックのカスタム実装を提供します。音声合成データを受け取り、それを再生可能なオーディオトラックに変換する役割を担います。これにより、Discordボットはリアルタイムで生成される音声ストリームを効率的に処理し、Discord上でスムーズに再生することができます。

```java
package jp.livlog.cotogoto.api.discord.source;

import java.util.Base64;

import com.sedmelluq.discord.lavaplayer.container.MediaContainerDescriptor;
import com.sedmelluq.discord.lavaplayer.source.AudioSourceManager;
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo;
import com.sedmelluq.discord.lavaplayer.track.DelegatedAudioTrack;
import com.sedmelluq.discord.lavaplayer.track.InternalAudioTrack;
import com.sedmelluq.discord.lavaplayer.track.playback.LocalAudioTrackExecutor;

public class CustomInputStreamAudioTrack extends DelegatedAudioTrack {

    private final byte[]                         bytes;

    private final MediaContainerDescriptor       containerTrackFactory;

    private final CustomInputStreamSourceManager sourceManager;

    public CustomInputStreamAudioTrack(
            final AudioTrackInfo trackInfo,
            final MediaContainerDescriptor containerTrackFactory,
            final CustomInputStreamSourceManager sourceManager) {

        super(trackInfo);
        this.bytes = Base64.getDecoder().decode(trackInfo.identifier);
        this.containerTrackFactory = containerTrackFactory;
        this.sourceManager = sourceManager;
    }


    @Override
    public void process(final LocalAudioTrackExecutor localExecutor) throws Exception {

        try (var inputStream = new CustomSeekableInputStream(this.bytes)) {
            final var internalTrack = (InternalAudioTrack) this.containerTrackFactory.createTrack(this.trackInfo, inputStream);
            this.processDelegate(internalTrack, localExecutor);
        } catch (final FriendlyException e) {
            return;
        }
    }


    public MediaContainerDescriptor getContainerTrackFactory() {

        return this.containerTrackFactory;
    }


    @Override
    protected AudioTrack makeShallowClone() {

        return new CustomInputStreamAudioTrack(this.trackInfo, this.containerTrackFactory, this.sourceManager);
    }


    @Override
    public AudioSourceManager getSourceManager() {

        return this.sourceManager;
    }
}
```

### CustomSeekableInputStream
`CustomSeekableInputStream` は、`SeekableInputStream` の拡張であり、特定のオーディオデータへのランダムアクセスを可能にします。これは、リアルタイムの音声ストリーミングの場合に特に重要です。オーディオデータが連続して流れるため、特定の部分にアクセスするための柔軟性が必要となります。

```java
package jp.livlog.cotogoto.api.discord.source;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.Collections;
import java.util.List;

import com.sedmelluq.discord.lavaplayer.tools.io.ExtendedBufferedInputStream;
import com.sedmelluq.discord.lavaplayer.tools.io.SeekableInputStream;
import com.sedmelluq.discord.lavaplayer.track.info.AudioTrackInfoProvider;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class CustomSeekableInputStream extends SeekableInputStream {

    private final ByteArrayInputStream        inputStream;

    private final ExtendedBufferedInputStream bufferedStream;

    private long                              position;

    public CustomSeekableInputStream(final byte[] bytes) {

        super(bytes.length, 0);
        this.inputStream = new ByteArrayInputStream(bytes);
        this.bufferedStream = new ExtendedBufferedInputStream(this.inputStream);
    }


    @Override
    public int read() throws IOException {

        final var result = this.bufferedStream.read();
        if (result >= 0) {
            this.position++;
        }
        return result;
    }


    @Override
    public int read(final byte[] b, final int off, final int len) throws IOException {

        final var read = this.bufferedStream.read(b, off, len);
        this.position += read;
        return read;
    }


    @Override
    public long skip(final long n) throws IOException {

        final var skipped = this.bufferedStream.skip(n);
        this.position += skipped;
        return skipped;
    }


    @Override
    public int available() throws IOException {

        return this.bufferedStream.available();
    }


    @Override
    public synchronized void reset() throws IOException {

        throw new IOException("mark/reset not supported");
    }


    @Override
    public boolean markSupported() {

        return false;
    }


    @Override
    public void close() throws IOException {

        try {
            this.inputStream.close();
        } catch (final IOException e) {
            CustomSeekableInputStream.log.debug("Failed to close inputStream", e);
        }
    }


    @Override
    public long getPosition() {

        return this.position;
    }


    @Override
    public boolean canSeekHard() {

        return false;
    }


    @Override
    protected void seekHard(final long position) {

        // 実装されていません
    }


    @Override
    public List <AudioTrackInfoProvider> getTrackInfoProviders() {

        return Collections.emptyList();
    }
}
```

# 6. 実装方法の詳細
1. **カスタムソースマネージャの作成**: `CustomInputStreamSourceManager` クラスを作成し、必要なメソッドを実装します。このクラスはLavaPlayerの内部メカニズムと連携して、新しい種類のオーディオソースを認識し、処理します。
2. **ソースの登録**: LavaPlayerの `AudioPlayerManager` にこの新しいソースマネージャを登録します。これにより、LavaPlayerの標準的なオーディオプレイヤー機能と統合され、カスタムソースの使用が可能になります。
3. **オーディオの再生**: `AudioPlayerManager` に `InputStream` を渡し、音声の再生を開始します。このステップでは、リアルタイムで生成される音声データを効率的に取り扱い、ユーザーに快適な聴覚体験を提供します。

# 7. まとめ
カスタム`InputStream`を用いることで、Discordボット開発者はリアルタイムでの音声合成と再生を容易に実現できます。この実装により、Discordボットの機能がさらに拡張され、ユーザーエクスペリエンスが向上します。