Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails以外の開発一般

Go:Goで作るDiscord用読み上げbot

まえがき

皆様こんにちは、BPSの協力会社として横浜を拠点に活動しております、株式会社ECNのFuseです。

突然ですが、みなさんはDiscord、使ってますか?
私もプライベートでよく使っており、声が出せない環境故、shovelという一般的に広く普及している読み上げbotを愛用しています。
ですが…

解決したい問題

ここ最近、利用者が多いからなのか、bot君の声がかすれたり、
とぎれとぎれになったりとにかく死にかけです。
なので内輪用のクローズドな読み上げbotを作って、少しでもスムーズに会話したいと思います。

目次

  1. まえがき
  2. 解決したい問題
  3. 完成品のイメージと完成したコード
  4. 完成品の動画
  5. 用意するもの(私の環境)
  6. GoでDiscordのBotを作ろう
  7. 音声を読み上げさせよう
    • 音声生成の流れをつかもう
    • Goで音声を生成させよう
      • Go言語でVOICEVOXのAPIを叩く
      • バイナリデータをwavファイルとして書き出す
    • GoでVCにつなごう
      • VCの接続状態を管理できる連想配列を作ろう
      • 接続と退出をコマンドで行えるようにしよう
    • Goで音声を再生しよう
      • 音声を再生する関数を作ろう
      • 生成して再生する一連の流れを作ろう
  8. あとがき

  9. 参考記事

完成品のイメージと完成したコード

Fuses-Garage/TTSBot - GitHub

注意

このコードは制作当時(2023年5月)の環境を想定したもので、
現在はdiscord側の仕様変更などで正常に動作しない可能性があります。

完成品の動画

  • 使用音源 VOICEVOX:春日部つむぎ1
  • アイコン画像 CHARAT2

用意するもの(私の環境)

  • Go:1.19
  • VOICEVOX:Ver0.13.3
  • ffmpeg: version 6.0
  • gcc version:12.2.0

GoでDiscordのBotを作ろう

千里の道も一歩から、まずはGo言語のライブラリDiscordGoで簡単なオウム返しbotを作ってみます。
DISCORD DEVELOPER PORTALでbotのトークンを用意したら、
こんなコードを打ち込んでビルドしてみましょう。

// main.go
package main

import (
    "fmt"
    "os"
    "os/signal"
    "syscall"
    "github.com/Fuses-Garage/TTSBot/script"
    "github.com/bwmarrin/discordgo"
)

func main() {
    dg, err := discordgo.New("Bot " + os.Getenv("TTS_BOT_TOKEN"))//トークンは環境変数にしてログイン
    if err != nil {//エラー発生時
        fmt.Println("error creating Discord session,", err)
        return
    }

    dg.AddHandler(script.OnMessageCreate)//メッセージが投稿されたら実行
    err = dg.Open()//セッション開始
    if err != nil {//エラーが起きたら
        fmt.Println("error opening connection,", err)
        return
    }
    // 終了時にちゃんとクローズするように
    defer dg.Close()

    fmt.Println("ログイン成功!")
    stopBot := make(chan os.Signal, 1)
    signal.Notify(stopBot, syscall.SIGINT, syscall.SIGTERM, os.Interrupt, os.Kill)
    <-stopBot
}

main.goでは、botとしてdiscordにログインし、discord内で起きる様々なイベントに
関数を紐づけていく処理を行います。

// script/handlers.go
package script

import (
    "github.com/bwmarrin/discordgo"
)

func OnMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate) {//メッセージが投稿されたら呼ばれます
    u := m.Author//uはmの発信者
    if !u.Bot {//発信元が人間なら
        SendMessage(s, m.ChannelID, m.Content)//メッセージをオウム返し
    }

 }

script/handlers.goOnMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate)関数では
メッセージの投稿主が人間かを確認し、同じ内容のメッセージを投稿させます。
ここで人間かどうかの確認を忘れると大惨事が起きるので気をつけましょう。

// script/messenger.go
package script

import (
    "log"
    "github.com/bwmarrin/discordgo"
)
func SendMessage(s *discordgo.Session, channelID string, msg string)(e error) {//メッセージを指定されたチャンネルに投稿します
    _, err := s.ChannelMessageSend(channelID, msg)//送ります
    log.Println(">>> " + msg)
    if err!=nil{
        panic(err)
    }
    return err//エラーデータを返します
}
func SendEmbedWithField(s *discordgo.Session, channelID, title, desc string, field []*discordgo.MessageEmbedField) (e error) { //埋め込みメッセージを指定されたチャンネルに投稿します
    embed := &discordgo.MessageEmbed{
        Author:      &discordgo.MessageEmbedAuthor{},
        Color:       0x880088,
        Title:       title,
        Description: desc,
        Fields:      field,
    }
    _, err := s.ChannelMessageSendEmbed(channelID, embed)
    return err //エラーデータを返します
}
func SendEmbed(s *discordgo.Session, channelID, title, desc string) (e error) { //埋め込みメッセージを指定されたチャンネルに投稿します
    embed := &discordgo.MessageEmbed{
        Author:      &discordgo.MessageEmbedAuthor{},
        Color:       0x880088,
        Title:       title,
        Description: desc,
    }
    _, err := s.ChannelMessageSendEmbed(channelID, embed)
    return err //エラーデータを返します
}
こぼれ話「埋め込みメッセージ」
SendEmbed関数を使うと埋め込みメッセージを投稿することができ、
タイトルや横のバーの色を指定したいかにもbotっぽいシステムメッセージを扱えます。

参考:Discord Developer Portal — Documentation — Webhook

script/handlers.goSendMessage(s *discordgo.Session, channelID string, msg string)(e error)関数では指定されたチャンネルにメッセージを投稿します。また、エラーが出たらpanicするようにしました。
これをビルドして実行すると、投稿された内容が帰ってくるようになるはずです。

音声を読み上げさせよう

音声生成の流れをつかもう

まずはVOICEVOXを起動して、読み上げたい文字を入力…ではなくhttp://localhost:50021/docsにアクセス。
すると…

なんと、ドキュメントのページにたどり着いたではありませんか。
アプリケーション版VOICEVOXは起動中、ローカルにHTTPサーバを立ち上げます。
試しにcurlで文章を投げてみましょう。

  curl -X 'POST' \
  'http://localhost:50021/audio_query?text=%E3%81%AF%E3%82%8D%E3%82%8F%EF%BC%81&speaker=1' \
  -H 'accept: application/json' \
  -d ''

すると、こんなJSONが帰ってきました。

{
  "accent_phrases": [
    {
      "moras": [
        {
          "text": "ハ",
          "consonant": "h",
          "consonant_length": 0.09226179867982864,
          "vowel": "a",
          "vowel_length": 0.10032831877470016,
          "pitch": 5.780645847320557
        },
        {
          "text": "ロ",
          "consonant": "r",
          "consonant_length": 0.04054700583219528,
          "vowel": "o",
          "vowel_length": 0.12944190204143524,
          "pitch": 5.934414863586426
        },
        {
          "text": "ワ",
          "consonant": "w",
          "consonant_length": 0.07369545847177505,
          "vowel": "a",
          "vowel_length": 0.20625032484531403,
          "pitch": 5.864794731140137
        }
      ],
      "accent": 2,
      "pause_mora": null,
      "is_interrogative": false
    }
  ],
  "speedScale": 1,
  "pitchScale": 0,
  "intonationScale": 1,
  "volumeScale": 1,
  "prePhonemeLength": 0.1,
  "postPhonemeLength": 0.1,
  "outputSamplingRate": 24000,
  "outputStereo": false,
  "kana": "ハロ'ワ"
}

このJSONが音声を生成するためのデータになります。これをもとに音声を生成してみましょう。

    curl -X POST 'http://localhost:50021/synthesis?speaker=1&enable_interrogative_upspeak=true' -H 'accept:audio/wav' -H "Content-Type:application/json"
-d'{
    "accent_phrases": [
      {
        "moras": [
          {
            "text": "ハ",
            "consonant": "h",
            "consonant_length": 0.09226179867982864,
            "vowel": "a",
            "vowel_length": 0.10032831877470016,
            "pitch": 5.780645847320557
          },
          {
            "text": "ロ",
            "consonant": "r",
            "consonant_length": 0.04054700583219528,
            "vowel": "o",
            "vowel_length": 0.12944190204143524,
            "pitch": 5.934414863586426
          },
          {
            "text": "ワ",
            "consonant": "w",
            "consonant_length": 0.07369545847177505,
            "vowel": "a",
            "vowel_length": 0.20625032484531403,
            "pitch": 5.864794731140137
          }
        ],
        "accent": 2,
        "pause_mora": null,
        "is_interrogative": false
      }
    ],
    "speedScale": 1,
    "pitchScale": 0,
    "intonationScale": 1,
    "volumeScale": 1,
    "prePhonemeLength": 0.1,
    "postPhonemeLength": 0.1,
    "outputSamplingRate": 24000,
    "outputStereo": false,
    "kana": "ハロワ"
  }'

送り付けたら、バイナリファイルが帰ってきました。
流れはつかめたので、これをGoで実装してみましょう。

Goで音声を生成させよう

Go言語でVOICEVOXのAPIを叩く

まずはbyte配列をVOICEVOXからもらってくる関数を作ります。

// script/synth.go(一部抜粋)
func getBinary(s string) ([]byte, error) {//バイナリデータをもらってくる
    str := s
    if utf8.RuneCountInString(str) > 30 {//あまりにも文章が長いときは
        slice := []rune(str)                            //ルーンに変換しないと二バイト文字がバグる
        strarr := []string{string(slice[:30]), "いかりゃく"} //30文字で切って以下略をつける
        str = strings.Join(strarr, " ")//くっ付ける
    }
    urlParts:=[]string{"http://localhost:50021/audio_query?text=",url.QueryEscape(str),"&speaker=8"};
    url_query := strings.Join(urlParts,"")//URL組み立て
    req, _ := http.NewRequest("POST", url_query, nil)//POSTでリクエスト
    req.Header.Set("accept", "application/json")//ヘッダをセット

    client := new(http.Client)//クライアント生成
    resp, err := client.Do(req)//リクエスト
    if err != nil {
        log.Printf("error: %v", err)
        return nil, err
    }
    url_synth := "http://localhost:50021/synthesis?speaker=8&enable_interrogative_upspeak=true"//音声生成用URL
    req_s, _ := http.NewRequest("POST", url_synth, resp.Body)//POSTでリクエスト
    req_s.Header.Set("accept", "audio/wav")//ヘッダをセット
    req_s.Header.Set("Content-Type", "application/json")//ヘッダをセット
    resp_s, err := client.Do(req_s)//リクエスト
    if err != nil {
        log.Printf("error: %v", err)
        return nil, err
    }
    defer resp_s.Body.Close()//いらなくなったら閉じる
    buff := bytes.NewBuffer(nil)//バッファ生成
    if _, err := io.Copy(buff, resp_s.Body); err != nil {//移す
        log.Printf("error: %v", err)
        return nil, err
    }
    return buff.Bytes(), nil//バッファを返す
}

getBinary(s string) ([]byte, error)関数では、先ほどの一連の流れを行い、帰ってきたバイナリデータを返します。また、長すぎる文章は30文字で切るようにしています。

こぼれ話「speaker=8の理由」
"speaker="の部分では音声合成でしゃべってくれるキャラクターを指定しています。
8番に対応するキャラクターは春日部つむぎちゃん。これはただの私の好みです。

参考:【C#】VOICEVOXのspeaker一覧を取得するサンプルとID一覧

バイナリデータをwavファイルとして書き出す

次にwavファイルを生成する関数を作ります。

// script/synth.go(一部抜粋)
func makeWaveFile(b []byte, guildID string) (string, error) {//音声を生成する関数
    max := new(big.Int)//ファイル名の重複を避けるための乱数
    max.SetInt64(int64(1000000))//100万通り
    r, err := rand.Int(rand.Reader, max) //乱数生成
    if err != nil {
        log.Printf("error: %v", err)
        return "", err
    }
    path := fmt.Sprintf("%s_%d.wav", guildID, r) //ファイル名の重複を避ける
    file, _ := os.Create(path)//ファイル生成
    defer func() {
        file.Close()//終わったら閉じる
    }()
    file.Write(b)//ファイルにデータを書き込む
    return path, nil//ファイルのパスを返す
}

makeWaveFile(b []byte, guildID string) (string, error)関数では、discordサーバーごとに一意なguildIDと乱数を結合することによって、ファイル名の衝突を避けていきます。おそらくほかにももっといい方法があると思いますが、初学者の自分には思いつきませんでした。

GoでVCにつなごう

次は生成した音声を再生させるため、Botをボイスチャットに接続させていきます。ですがその前に準備しなければいけないものがあります。

VCの接続状態を管理できる連想配列を作ろう

このBotを使うサーバは1つだけではなく、
読み上げの対象になる可能性があるテキストチャンネルも1つではありません。
また、管理したい情報が後々増えていく可能性もあるでしょう。
なので、情報をguildIDをキー。構造体vcDataをバリューとした連想配列で管理していきます。

// script/synth.go(一部抜粋)
type vcData struct { //読み上げデータ
    connection *discordgo.VoiceConnection //音声を再生するコネクション
    channelID  string                     //読み上げるテキストチャンネルのID
    queue *[]string                       //読み上げたい音声のパスのキュー(のアドレス)
}

今回構造体に含める情報は、コネクションのアドレス読み上げるテキストチャンネルのID読み上げたい音声のパスのキューです。また、構造体として管理することで、後々のデータの追加も可能になっています。
その後var vcDict map[string]vcDataで連想配列を宣言するのですが、宣言直後の連想配列は中身がnil
そのままキーバリューを追加しようとするとpanicを吐いてしまいます。

なのでnilチェックは必ず行いましょう。

連想配列を定義したら、VCへの接続と切断を行う関数を作っていきましょう。

// script/synth.go(一部抜粋)
func Connect(s *discordgo.Session, m *discordgo.MessageCreate) {//VCに接続します
    if vcDict == nil {//vcDictがnilなら
        vcDict = make(map[string]vcData)//連想配列を生成
    }

    userstate, _ := s.State.VoiceState(m.GuildID, m.Author.ID)//呼び出したユーザはVCに入っているか?
    if userstate == nil {//入っているなら
        SendEmbed(s, m.ChannelID, "エラーが発生しました。", "呼び出す前にVCに参加してください。")
        return
    }
    _, ok := vcDict[m.GuildID]//そのサーバーの接続データは存在するか
    if ok {//存在するなら
        SendEmbed(s, m.ChannelID, "エラーが発生しました。", "すでにVCに接続中です。")
        return
    }
    vcsession, err := s.ChannelVoiceJoin(m.GuildID, userstate.ChannelID, false, false)//VCに接続
    if err != nil {
        SendEmbed(s, m.ChannelID, "エラーが発生しました。", err.Error())
        return
    } else {
        txtchan, _ := s.Channel(m.ChannelID)
        tcname := txtchan.Name
        voicechan, _ := s.Channel(userstate.ChannelID)
        vcname := voicechan.Name
        field := []*discordgo.MessageEmbedField{
            &discordgo.MessageEmbedField{
                Name:  "読み上げ元",
                Value: tcname,
            },
            &discordgo.MessageEmbedField{
                Name:  "読み上げ先",
                Value: vcname,
            },
        }
        SendEmbedWithField(s, m.ChannelID, "読み上げ開始", "これより、読み上げを開始します。", field)
        slice:=make([]string, 0,10)
        newData := vcData{vcsession, m.ChannelID, &slice}//接続データを生成
        vcDict[m.GuildID] = newData//サーバIDに対応付ける
    }
}
func Disconnect(s *discordgo.Session, m *discordgo.MessageCreate) {//VCから抜ける
    v, ok := vcDict[m.GuildID]//データ取得
    if !ok {//データが存在しない=VCに入ってない
        SendEmbed(s, m.ChannelID, "エラーが発生しました。", "まだVCに参加していません。")
        return
    }
    err := v.connection.Disconnect()//退出
    if err != nil {//退出時にエラーが起きたら
        SendEmbed(s, m.ChannelID, "エラーが発生しました。", err.Error())
    } else {//起きなかったら
        delete(vcDict, m.GuildID)//接続データを削除
        SendEmbed(s, m.ChannelID, "退出完了", "正常に退出しました。")
    }
}

すでに接続しているチャンネルに接続しようとしたり、まだ接続していないのに退出しようとするとエラーが返ってきます。そのエラーをユーザーに伝えたり、接続時の情報をユーザに伝えたりすることで、何が起きているのかをユーザにわかりやすくしていきましょう。

また、接続時はvcDictがnilかどうかを必ずチェックしておきましょう。
怠ると異常終了で大ハマりします。

接続と退出をコマンドで行えるようにしよう

そのあとは、2つの関数を簡単にコマンドで起動できるようにしましょう。
OnMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate)を次のように作り変えます。

// script/handler.go(一部抜粋)
func OnMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate) { //メッセージが投稿されたら呼ばれます
    u := m.Author //uはmの発信者
    if !u.Bot {   //発信元が人間なら
        if strings.HasPrefix(m.Content, prefix) {
            command := strings.Split(m.Content, " ")
            switch command[1] {

            case "s":
                Connect(s, m) //接続処理
            case "e":
                Disconnect(s, m) //今いる通話チャンネルから抜ける
            }
        }
    }
}

コマンドといっても実装は単純で、メッセージの中身があらかじめ定めたprefixから始まるなら、
その後の内容に応じ処理を振り分ける、といった単純な実装になります。
古くから多くのbot作者に愛される実装方法です。
これで、botがVCに入退室できるようになりました。

Goで音声を再生しよう

音声を再生する関数を作ろう

ここからが正念場、生成した音声を読み上げていきます。接続データとパスを指定して音声を読み上げる関数を作りましょう。

// script/synth.go(一部抜粋)
func play(v *vcData, path string, force bool) { //指定されたパスの音声を再生またはキューに追加します。
    if len(*v.queue) > 0 && !force { //現在再生中かつ強制再生フラグがオフなら
        *v.queue = append(*v.queue, path) //キューに追加
        return
    }
    *v.queue = append(*v.queue, path) //キューに追加
    vc := v.connection                //コネクションのデータを取り出す
    vc.Speaking(true)                 //再生開始
    defer vc.Speaking(false)          //終わったら再生フラグを戻す
    defer os.Remove(path)             //終わったらファイルを消す
    defer func(v *vcData) {           //終わったら
        *v.queue = (*v.queue)[1:] //キューから終わった分を消す
        if len(*v.queue) > 0 {    //まだキューに残ってたら
            pt := (*v.queue)[0] //次に読み上げるパスを取得
            *v.queue = (*v.queue)[1:]
            play(v, pt, true) //強制再生
        }
    }(v)
    dgvoice.PlayAudioFile(vc, path, make(chan bool)) //再生開始
}

複数の音声が同時に再生されて音がめちゃくちゃにならないようにサーバごとにスライスを使って再生制御を行います。また、ストレージが音声ファイルでいっぱいにならないように、再生が終わったファイルは順次消去していきましょう。

生成して再生する一連の流れを作ろう

Play(v *VCData, path string, force bool)関数を作ったら、いよいよ一連の関数を流れで呼び出す関数を作りましょう。

// script/synth.go(一部抜粋)
func TTS(m *discordgo.MessageCreate) { //渡されたメッセージを読み上げる
    go func() { //ゴルーチンを使う
        v, ok := vcDict[m.GuildID] //接続データを取得
        if ok {                    //VC接続中なら
            if v.channelID == m.ChannelID { //書き込まれた先が読み上げ対象なら
                b, err := getBinary(m.Content) //バイナリデータを取得
                if err != nil {                //エラーが起きたら
                    log.Printf("error: %v", err)
                    return
                }
                var path string                        //ファイルのパス
                path, err = makeWaveFile(b, m.GuildID) //ファイルに書き込む
                if err != nil {                        //エラーが起きたら
                    log.Printf("error: %v", err)
                    return
                }
                play(&v, path, false) //再生
            }
        }
    }()
}

TTS(m *discordgo.MessageCreate)関数では今まで作った関数を順番に実行し、エラーが起きたら途中終了するといった動作を行うことで、メッセージを読み上げていきます。また、ゴルーチンを使うことで、複数のサーバーから同時に書き込みが行われても、並行して対応したり、読み上げ中に次の音声を準備することができるのです。

完成したら、OnMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate)を忘れずに次のように作り変えます。

// script/handler.go(一部抜粋)
func OnMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate) { //メッセージが投稿されたら呼ばれます
    u := m.Author //uはmの発信者
    if !u.Bot {   //発信元が人間なら
        if strings.HasPrefix(m.Content, prefix) {
            command := strings.Split(m.Content, " ")
            switch command[1] {

            case "s":
                Connect(s, m) //接続処理
            case "e":
                Disconnect(s, m) //今いる通話チャンネルから抜ける
            }
        } else {
            TTS(m) //読み上げ機能を呼び出す
        }
    }
}

これで書き込んだメッセージがこんな感じに読み上げられるはずです!

あとがき

長くなりましたがこれにて終了です!
最後までお読みいただき、ありがとうございました!

参考記事


株式会社ECNはPHP、JavaScriptを中心にお客様のご要望に合わせたwebサービス、システム開発を承っております。
ビジネスの最初から最後までサポートを行い
お客様のイメージに合わせたWebサービス、システム開発、デザインを行います。


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。