この記事はもともと別場所にて公開していました。
この記事はF# アドベントカレンダー 2020の 10 日目の記事です。
#概要
Discord のテキストチャンネルで流れてきた文字列を Google Cloud Text-to-Speech (TTS)を使って音声合成しボイスチャンネル(VC)に流してくれるような代読 Discord ボットを、 Discord クライアントライブラリDSharpPlusを使って F# で作りました。機能としては喋太郎や Shovelなどと同じですが、 Google Cloud Text-to-Speech を使うため、より高品質な音声を VC に流すことができます。
ソースコードはGitHub 上で公開しています。
#プロジェクト作成
新しいプロジェクトを作成してDSharpPlus
とGoogle.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 にフィードバックする部分を作ります。まずチャットメッセージが来た際にトリガされる関数onMessage
をmain
で登録します(ソースコード)。
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