Google Cloud TTSとDSharpPlusを使って代読DiscordボットをF#で作る

2020/12/10

(Last edited on 2022/01/10)

この記事はF# アドベントカレンダー 2020の 10 日目の記事です。

# 概要

Discord のテキストチャンネルで流れてきた文字列を Google Cloud Text-to-Speech (TTS)を使って 音声合成しボイスチャンネル(VC)に流してくれるような代読 Discord ボットを、 Discord クライアントライブラリDSharpPlusを使って F# で作りました。機能としては喋太郎Shovelなどと同じですが、 Google Cloud Text-to-Speech を使うため、より高品質な音声を VC に流すことができます。

ソースコードはGitHub 上で公開しています

# プロジェクト作成

新しいプロジェクトを作成してDSharpPlusGoogle.Cloud.TextToSpeech.V1を追加します。 DSharpPlusは複数のパッケージに分かれているので、必要なものを全て入れます。 今回は VC に接続するために必要なVoiceNextと、ユーザがコマンドを使用してボットを操作できる ようにするためCommandsNextを追加しました。

なお 2020 年 12 月 9 日現在、DSharpPlus の最新安定版(v3.2.3)は Discord の VC に接続できないバグがあり、 これを行うためにはプレリリース(4.0.0-rc1)を使う必要があります。

追記(2022年1月10日):下のコマンドが間違っていたので修正しました。ご報告していただきありがとうございました。

$ dotnet new console -lang="F#" -o daidoquer
$ dotnet nuget add source https://nuget.emzi0767.com/api/v3/index.json -n dsharp
$ dotnet add package Google.Cloud.TextToSpeech.V1
$ dotnet add package DSharpPlus
$ dotnet add package DSharpPlus.VoiceNext
$ dotnet add package DSharpPlus.CommandsNext

# Google Cloud TTS を使用した音声合成

まず Google Cloud TTS を用いて音声合成を行う関数getVoiceAsyncを作成します (ソースコード)。 大まかに言って、必要な設定を行ってTextToSpeechClient.SynthesizeSpeechAsyncを呼び出せば、 最終的にbyte[]として結果を得ることができます。

let getVoiceAsync text (langCode, name) (outStream: VoiceTransmitSink) =
    async {
        let! client =
            TextToSpeechClient.CreateAsync()
            |> Async.AwaitTask

        let input = new SynthesisInput(Text = text)

        let voice =
            new VoiceSelectionParams(LanguageCode = langCode, Name = name)

        let config =
            new AudioConfig(AudioEncoding = AudioEncoding.Mp3)

        let request =
            new SynthesizeSpeechRequest(Input = input, Voice = voice, AudioConfig = config)

        let! response =
            client.SynthesizeSpeechAsync(request)
            |> Async.AwaitTask

        let bytes = response.AudioContent.ToByteArray()

次に、得られた音声を PCM S16LE にエンコードします。 Discord はサンプリングレートが 48000Hz のステレオ PCM 音声を Opus でエンコードしたものを要求しますが、 PCM S16LE を DSharpPlus に渡せば Opus エンコードは DSharpPlus が行ってくれます。 エンコードは、ffmpeg を外部コマンドとして起動し行います。

        use ffmpeg =
            Process.Start
                (new ProcessStartInfo(FileName = "ffmpeg",
                                      Arguments = "-i pipe:0 -ac 2 -f s16le -ar 48000 pipe:1",
                                      RedirectStandardInput = true,
                                      RedirectStandardOutput = true,
                                      UseShellExecute = false))

        let! writer =
            async {
                do! ffmpeg.StandardInput.BaseStream.WriteAsync(bytes, 0, bytes.Length)
                    |> Async.AwaitTask

                ffmpeg.StandardInput.Close()
            }
            |> Async.StartChild

        let! reader =
            async {
                do! ffmpeg.StandardOutput.BaseStream.CopyToAsync(outStream)
                    |> Async.AwaitTask
            }
            |> Async.StartChild

        do! [ writer; reader ]
            |> Async.Parallel
            |> Async.Ignore
    }

なおこのコードを実行する際には、Google Cloud TTS を使用するための認証トークン (サービスアカウントキー)を含んだ JSON ファイルを 環境変数GOOGLE_APPLICATION_CREDENTIALS経由で指定する必要があります。 サービスアカウントキーの作り方はGoogle Cloud TTS のドキュメントを参考してください。

# Bot の作成

次に DSharpPlus を用いて Discord ボットを作成します。 予めDiscord Developer Portalでボットを作成し、 トークンを取得しておきます。

単に Discord サーバに接続するだけのコードはおおよそ次のようになっています(ソースコード)。 設定を行って Discord サーバに接続しTask.Delay(-1)で無限に待ち続けます。 トークンは環境変数DISCORD_TOKENとして指定します。

[<EntryPoint>]
let main argv =
    let token =
        Environment.GetEnvironmentVariable "DISCORD_TOKEN"

    if token = null
    then failwith "Set envvar DISCORD_TOKEN and LOGFILE"

    printfn "Preparing..."

    let conf =
        new DiscordConfiguration(Token = token, TokenType = TokenType.Bot, AutoReconnect = true)

    let client = new DiscordClient(conf)

    let voice = client.UseVoiceNext()

    printfn "Connecting to the server..."

    client.ConnectAsync()
    |> Async.AwaitTask
    |> Async.RunSynchronously

    printfn "Done."

    Task.Delay(-1)
    |> Async.AwaitTask
    |> Async.RunSynchronously
    |> ignore

    0 // return an integer exit code

ここにイベントハンドラなどを追加して意味のある動作を行います。

まずボットを VC に参加させたり(join)退出させたり(leave)するためのコマンドを作成します。 DSharpPlus ではBaseCommandModuleを継承したクラスを作成し、 属性をつけたメソッドをコマンドごとに定義することでこれを行います (ソースコード)。 join/leave する際には、実際に join/leave しても問題ないかを確認し、 問題がある場合はチャットでその旨を報告するようにしています。

このあたりはDSharpPlus の exampleを参考にしました。

type DaidoquerCommand() =
    inherit BaseCommandModule()

    member private this.RespondAsync (ctx: CommandContext) (msg: string) =
        ctx.RespondAsync(msg)
        |> Async.AwaitTask
        |> Async.Ignore

    member private this.Wrap (ctx: CommandContext) (atask: Async<unit>) =
        async {
            try
                do! atask
            with
            | Failure (msg) ->
                eprintfn "Error: %s" msg
                do! this.RespondAsync ctx ("Error: " + msg)
            | err ->
                eprintfn "Error: %A" err
                do! this.RespondAsync ctx "Error: Something goes wrong on our side."
        }
        |> Async.StartAsTask :> Task

    [<Command("join"); Description("Join the channel")>]
    member public this.Join(ctx: CommandContext) =
        async {
            let vnext = ctx.Client.GetVoiceNext()

            if vnext = null
            then failwith "VNext is not enabled or configured."

            let vnc = vnext.GetConnection(ctx.Guild)

            if vnc <> null
            then failwith "Already connected in this guild."

            if ctx.Member = null
               || ctx.Member.VoiceState = null
               || ctx.Member.VoiceState.Channel = null then
                failwith "You are not in a voice channel."

            let chn = ctx.Member.VoiceState.Channel

            eprintfn "Connecting to %s..." chn.Name
            let! vnc = vnext.ConnectAsync(chn) |> Async.AwaitTask
            eprintfn "Connected to %s" chn.Name

            do! vnc.SendSpeakingAsync(false) |> Async.AwaitTask
            do! this.RespondAsync ctx ("Connected to" + chn.Name)
        }
        |> this.Wrap ctx

    [<Command("leave"); Description("Leave the channel")>]
    member public this.Leave(ctx: CommandContext) =
        async {
            let vnext = ctx.Client.GetVoiceNext()

            if vnext = null
            then failwith "VNext is not enabled or configured."

            let vnc = vnext.GetConnection(ctx.Guild)
            if vnc = null then failwith "Not connected in this guid."

            eprintfn "Disconnecting..."
            vnc.Disconnect()
            eprintfn "Disconnected"

            do! this.RespondAsync ctx "Disconnected"
        }
        |> this.Wrap ctx

DaidoquerCommandを使うためには、これをmainで登録する必要があります。 登録の際にはコマンドのプレフィクスを自由に指定できます。 ここではボットの名前が Daidoquer なので!ddqを使っています (ソースコード)。

    let cconf =
        new CommandsNextConfiguration(EnableMentionPrefix = true, StringPrefixes = [ "!ddq" ])

    let commands = client.UseCommandsNext(cconf)
    commands.RegisterCommands<DaidoquerCommand>()

これで!ddq join!ddq leaveとすることで、VC に参加したり退出したりできるようになりました。 最後に、代読器の肝である、 チャットメッセージが来た際にそれを VC にフィードバックする部分を作ります。 まずチャットメッセージが来た際にトリガされる関数onMessagemainで登録します (ソースコード)。

client.add_MessageCreated
    (new Emzi0767.Utilities.AsyncEventHandler<DiscordClient, MessageCreateEventArgs>(fun s e ->
    (fun () -> onMessage s e voice |> Async.StartAsTask :> Task)
    |> Task.Run
    |> ignore

    Task.CompletedTask))

関数onMessageでは、チャットメッセージを Google Cloud TTS を使って音声合成し、 DSharpPlus 経由で VC にその音声を流します。 (ソースコード)。

このあたりもDSharpPlus の exampleを参考にしています。

exception IgnoreEvent of unit

let onMessage (client: DiscordClient) (args: MessageCreateEventArgs) (voice: VoiceNextExtension) =
    let ignoreEvent () = raise (IgnoreEvent())

    async {
        try
            let msg = args.Message.Content

            if args.Author.IsCurrent || msg.StartsWith("!ddq")
            then ignoreEvent ()

            let vnc = voice.GetConnection(args.Guild)
            if vnc = null then raise (IgnoreEvent())

            while vnc.IsPlaying do
                do! vnc.WaitForPlaybackFinishAsync()
                    |> Async.AwaitTask

            eprintfn "%s" msg

            try
                do! vnc.SendSpeakingAsync(true) |> Async.AwaitTask
                let txStream = vnc.GetTransmitSink()
                do! getVoiceAsync msg ("ja-JP", "ja-JP-Wavenet-B") txStream
                do! txStream.FlushAsync() |> Async.AwaitTask

                do! vnc.WaitForPlaybackFinishAsync()
                    |> Async.AwaitTask

                do! vnc.SendSpeakingAsync(false) |> Async.AwaitTask
            with err ->
                do! vnc.SendSpeakingAsync(false) |> Async.AwaitTask
                raise err
        with
        | IgnoreEvent () -> ()
        | Failure (msg) ->
            eprintfn "Error: %s" msg

            do! args.Message.RespondAsync("Error: " + msg)
                |> Async.AwaitTask
                |> Async.Ignore
        | err ->
            eprintfn "Error: %A" err

            do! args.Message.RespondAsync("Error: Something goes wrong on our side.")
                |> Async.AwaitTask
                |> Async.Ignore
    }

# 実行

dotnet runで起動できます。Discord のトークンと Google Cloud TTS のトークンを 環境変数として指定する必要があります。

$ DISCORD_TOKEN="XXXXXXXXXXXXXX" GOOGLE_APPLICATION_CREDENTIALS="YYYYYYYYYYYYY.json" dotnet run
このエントリーをはてなブックマークに追加