Java
discord
Polly
DiscordDay 20

DiscordのVoiceChannelにAmazon Pollyで生成された音声を流してみる。

はじめに

この記事はDiscord Advent Calendar 2017 20日目の記事になります。
Discord上で動くカスタマイズ可能なチャット読み上げbotが欲しくなり、Amazon Pollyを使って作ってみよう。というお話です。

想定する挙動

1.VoiceChannel:Generalに常駐
 投稿の度にinとoutを繰り返すと通知がonになっているユーザにはうるさいので。
2.TextChannelに投稿された内容をキャッチ
3.投稿された内容をPollyで音声へ変換
4.Pollyで変換した音声を再生

使用する主なツール

Java
Discord4j
Amazon Polly

まずはBotの起動から

※Botの作り方の基本的な話(tokenの取得など)は今回省略します。

まずはDiscord4jのサンプルコードをベースに実行クラスを実装

Main.java
public class Main {
    private static String TOKEN = "BOT_TOKEN";
    public static void main(final String[] args) {
        final IDiscordClient client = Main.createClient(TOKEN, true);
        final EventDispatcher dispatcher = client.getDispatcher();
        dispatcher.registerListener(new Listener());
    }

    public static IDiscordClient createClient(final String token, final boolean login) {
        final ClientBuilder clientBuilder = new ClientBuilder().withToken(token);
        if (login) {
            return clientBuilder.login();
        }
        return clientBuilder.build();
    }
}

Listenerの実装

Discord4jではEventDispatcherにListenerを登録し、各種イベントを処理します。
Listnerの実装方法は2種類ありますが、今回はEventSubscriberアノテーションを使う形で実装。

Listner.java
public class Listener {
    private final Synthesizer polly = new Synthesizer();
    private final static long GUILD_ID = 0l;
    private final static long VOICE_CHANNEL_ID = 0l;
    //上記IDは事前に調べて設定

    @EventSubscriber
    // 音声を流したいChannelを指定してjoin
    public void onReadyEvent(final ReadyEvent event) {
        final IDiscordClient client = event.getClient();
        final IGuild guild = client.getGuildByID(GUILD_ID);
        final IVoiceChannel voiceChannel = guild.getVoiceChannelByID(VOICE_CHANNEL_ID);
        voiceChannel.join();
    }

    @EventSubscriber
    // channel内に投稿があった時に反応するイベント
    public void onMessageReceivedEvent(final MessageReceivedEvent event) {
        try {
            final IMessage message = event.getMessage();
            final IGuild guild = message.getGuild();
            if (guild.getLongID() != Listener.GUILD_ID) {
                log.info("joinしているChannelのサーバで起きたEventではありません");
                return;
            }
            final String content = message.getContent();
            final String speechContent = content.replaceAll("<.+>", "");
            if (speechContent.isEmpty()) {
                return;
            }
            final IAudioManager audioManager = guild.getAudioManager();
            final AudioInputStream input = this.polly.synthesize(speechContent, OutputFormat.Mp3);
            final AudioInputStreamProvider provider = new AudioInputStreamProvider(input);
            audioManager.setAudioProvider(provider);
            log.info("content:{}を再生します。", speechContent);
        } catch (IOException | UnsupportedAudioFileException e) {
            log.warn("MessageReceivedEvent処理中に例外発生", e);
        }
    }
}

IGuildオブジェクトから取得できるAudioManagerにIAudioProviderを登録することでbotからVoiceChannelに音声を流すことができます。
IAudioProviderを継承しているクラスはいくつかありますが、Java標準ライブラリと親和性の高いAudioInputStreamProviderを使用しています。

注意点

サーバーへのログイン

Discord上でGuildは登録しているサーバーを指します。
上記コードではReadyEvent発生時にGUILD_IDで指定してログインさせています。
大雑把な挙動としては準備ができたらログインするという認識で問題ないとは思いますが、
ReadyEventの詳細に関してはDiscordの公式ドキュメントを参照してください。

Eventの処理の制限

botが登録できるサーバーは1つだけではありません。
onMessageReceivedEventはbotユーザから見えている投稿全てに反応してしまいますので、
上記コードではあらかじめ動作させたいサーバーをGUID_IDで制限しています。
TextChannelが盛況なサーバーではChannelにも制限をかけるべきですが、今回は割愛。

絵文字の扱い

IMessageクラスのgetContentメソッドで、テキストチャンネルに投稿された内容を文字列で受け取ることができます。
この際、絵文字をTextChannelに投稿した場合、Contentには<:絵文字のALIAS:絵文字ID>といった形式の文字列が入ります。
読み上げさせると長々と絵文字IDを読み上げることになるので、少々雑ですが正規表現で削っています。

Pollyで音声を生成

Synthesizer.java
public class Synthesizer {
    private final AmazonPolly pollyClient;
    private final String languageCode = "ja-JP";
    private final Voice voice;
    private final Regions regions = Regions.AP_NORTHEAST_1;
    private final static String ACCESS_KEY = "ACCESS_KEY";
    private final static String SECRET_KEY = "SECRET_KEY";

    public Synthesizer() {
        final BasicAWSCredentials credentials = new BasicAWSCredentials(ACCESS_KEY, SECRET_KEY);
        this.pollyClient = AmazonPollyClientBuilder.standard()
                .withCredentials(new AWSStaticCredentialsProvider(credentials)).withRegion(this.regions).build();
        final DescribeVoicesRequest describeVoicesRequest = new DescribeVoicesRequest()
                .withLanguageCode(this.languageCode);
        final DescribeVoicesResult describeVoicesResult = this.pollyClient.describeVoices(describeVoicesRequest);
        final List<Voice> voices = describeVoicesResult.getVoices();
        // 0でTakumi
        // 1でMizuki
        this.voice = voices.get(0);
    }

    public AudioInputStream synthesize(final String text, final OutputFormat format)
            throws UnsupportedAudioFileException, IOException {
        final SynthesizeSpeechRequest synthReq = new SynthesizeSpeechRequest().withText(text)
                .withVoiceId(this.voice.getId()).withOutputFormat(format);
        final SynthesizeSpeechResult synthRes = this.pollyClient.synthesizeSpeech(synthReq);
        final AudioInputStream audioStream = AudioSystem.getAudioInputStream(synthRes.getAudioStream());
        return audioStream;
    }
}

サンプルコードを参考に実装。
PollyのライブラリからはInputStreamしか受け取ることはできませんが、Java標準のAudioSystemクラスを使用してAudioInputStreamに変換しています。

注意点

Pollyの無料枠は最初のリクエストから12ヶ月の間、1ヶ月500万文字。
Pollyでは、日本語では男性ボイス1種、女性ボイス1種の2種類が使用可能。

終わりに

Discord4jを使えば一通りのやりたいことはできるといった印象。
次はサーバー内のユーザ毎にPollyの音声設定を用意して、聞き取りやすい環境を作りたい。