├── .github └── workflows │ └── push.yml ├── .gitignore ├── .golangci.yml ├── LICENSE ├── Makefile ├── README.md ├── cmd └── discord-tts │ └── discord-tts.go ├── go.mod ├── go.sum ├── internal ├── logger │ └── logger.go ├── session │ ├── manager.go │ └── session.go └── voice │ ├── coefont_adapter.go │ ├── google_translate_adapter.go │ ├── google_tts_adapter.go │ └── voice_adapter.go └── sample.png /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: go-ci 2 | 3 | on: [push] 4 | 5 | jobs: 6 | setup: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: set up 10 | uses: actions/setup-go@v5 11 | - name: check out 12 | uses: actions/checkout@v2 13 | - name: Cache 14 | uses: actions/cache@v2.1.0 15 | with: 16 | path: ~/go/pkg/mod 17 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 18 | restore-keys: | 19 | ${{ runner.os }}-go- 20 | 21 | build: 22 | needs: setup 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v2 26 | - name: build 27 | run: go build ./... 28 | 29 | test: 30 | needs: setup 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v2 34 | - name: test 35 | run: go test ./... -v 36 | 37 | lint: 38 | needs: setup 39 | runs-on: ubuntu-latest 40 | steps: 41 | - uses: actions/checkout@v2 42 | - name: golangci-lint 43 | uses: golangci/golangci-lint-action@v6 44 | with: 45 | version: latest 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | .envrc 17 | 18 | .idea 19 | creds 20 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable-all: true 3 | disable: 4 | - wsl # 余計な改行をなるべく含まないようにすることで得られる見通しの良さを重視するため 5 | - nlreturn # 上記と同様 6 | - gosmopolitan # 現在はi18n/l10nを検討していないため 7 | - depguard # 規模的に依存関係の流れを厳格に管理する必要性はないあめ 8 | - forbidigo # いまのところ特に禁止したい表現はないため 9 | - gomnd # The linter 'gomnd' is deprecated 10 | - execinquery # The linter 'execinquery' is deprecated 11 | linters-settings: 12 | varnamelen: 13 | ignore-decls: 14 | - v *discordgo.VoiceStateUpdate # パッケージの使用例がその命名であるため 15 | - m *discordgo.MessageCreate # パッケージの使用例がその命名であるため 16 | revive: 17 | rules: 18 | - name: unexported-return 19 | disabled: true # ireturnへの対応を優先するため 20 | funlen: 21 | lines: 100 # デフォルトの60だと余計な関数の分割が発生するため 22 | statements: 60 # デフォルトの40だと余計な関数の分割が発生するため 23 | gomoddirectives: 24 | replace-allow-list: 25 | - github.com/jonas747/dca # 該当パッケージが壊れているので独自にパッチを当てたものを利用したいため 26 | cyclop: 27 | max-complexity: 24 # デフォルトの10だと余計な関数の分割が発生するため 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 takanakahiko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | go build cmd/discord-tts/discord-tts.go 3 | lint: 4 | docker run --rm -v $(shell pwd):/app -w /app golangci/golangci-lint:v1.58.1 golangci-lint run --fix 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # discord-tts 2 | 3 | text to speech bot for discord. 4 | (Support CoeFont voice.) 5 | 6 | ## require 7 | 8 | - go@latest 9 | - ffmpeg@4 10 | 11 | ## installation 12 | 13 | ```bash 14 | go install github.com/takanakahiko/discord-tts/cmd/discord-tts@latest 15 | ``` 16 | 17 | ## run discord-tts 18 | 19 | ```bash 20 | export TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 21 | export COEFONT_ACCESS_TOKEN=xxxxxxxxxxxxxxxxxx 22 | export COEFONT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxx 23 | discord-tts [--prefix=xxx] 24 | ``` 25 | 26 | ## usage 27 | 28 | 1. `@ join` : The bot enters the same voice chat as you 29 | 2. In same channel of 1 , send chat `hogehuga` : Bot talks to 'hogehuga' in voice chat. 30 | 31 | In this sample, the bot says "test". 32 | 33 | ![sample](./sample.png) 34 | 35 | ## custom prefix 36 | 37 | ```bash 38 | discord-tts --prefix=xxx 39 | ``` 40 | 41 | You can use it like this 42 | 43 | - `xxx join` 44 | - `xxx leave` 45 | 46 | ## debug 47 | 48 | ```bash 49 | export TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 50 | export COEFONT_ACCESS_TOKEN=xxxxxxxxxxxxxxxxxx 51 | export COEFONT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxx 52 | go run cmd/discord-tts/discord-tts.go 53 | ``` 54 | 55 | ## contribution 56 | 57 | Welcome 58 | -------------------------------------------------------------------------------- /cmd/discord-tts/discord-tts.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "os" 9 | "os/signal" 10 | "strconv" 11 | "strings" 12 | "syscall" 13 | 14 | "github.com/bwmarrin/discordgo" 15 | "github.com/takanakahiko/discord-tts/internal/logger" 16 | "github.com/takanakahiko/discord-tts/internal/session" 17 | ) 18 | 19 | //nolint:gochecknoglobals 20 | var ( 21 | sessionManager = session.NewTtsSessionManager() 22 | prefix = flag.String("prefix", "", "call prefix") 23 | clientID = "" 24 | ) 25 | 26 | func main() { 27 | flag.Parse() 28 | fmt.Println("prefix :", *prefix) 29 | 30 | discord, err := discordgo.New("Bot " + os.Getenv("TOKEN")) 31 | if err != nil { 32 | fmt.Println("Error logging in") 33 | fmt.Println(err) 34 | } 35 | 36 | discord.AddHandler(onReady) 37 | discord.AddHandler(onMessageCreate) 38 | discord.AddHandler(onVoiceStateUpdate) 39 | 40 | if err = discord.Open(); err != nil { 41 | fmt.Println(err) 42 | } 43 | defer func() { 44 | if err := discord.Close(); err != nil { 45 | logger.PrintError(err) 46 | } 47 | }() 48 | 49 | fmt.Println("Listening...") 50 | 51 | sc := make(chan os.Signal, 1) 52 | signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt) 53 | <-sc 54 | } 55 | 56 | func botName() string { 57 | // if prefix is "", you can call by mention 58 | if *prefix == "mention" { 59 | return "<@" + clientID + ">" 60 | } 61 | return *prefix 62 | } 63 | 64 | func onReady(discord *discordgo.Session, _ *discordgo.Ready) { 65 | clientID = discord.State.User.ID 66 | } 67 | 68 | // event by message. 69 | func onMessageCreate(discord *discordgo.Session, m *discordgo.MessageCreate) { 70 | { 71 | discordChannel, err := discord.Channel(m.ChannelID) 72 | if err != nil { 73 | log.Fatal(err) 74 | return 75 | } 76 | guild, err := discord.Guild(m.GuildID) 77 | if err != nil && !errors.Is(err, session.ErrTtsSessionNotFound) { 78 | log.Println(err) 79 | return 80 | } 81 | log.Printf( 82 | "onMessageCreate\n server: %s\n ch: %s\n user: %s\n message: %s\n", 83 | guild.Name, discordChannel.Name, m.Author.Username, m.Content) 84 | } 85 | 86 | // bot check 87 | if m.Author.Bot || strings.HasPrefix(m.Content, ";") { 88 | return 89 | } 90 | 91 | // "join" command 92 | if isCommandMessage(m.Content, "join") { 93 | if _, err := sessionManager.GetByGuildID(m.GuildID); err == nil { 94 | sendMessagef(discord, m.ChannelID, "Bot is already in voice-chat.") 95 | return 96 | } else if !errors.Is(err, session.ErrTtsSessionNotFound) { 97 | log.Println(err) 98 | return 99 | } 100 | ttsSession := session.NewTtsSession() 101 | if err := ttsSession.Join(discord, m.Author.ID, m.ChannelID); err != nil { 102 | logger.PrintError(err) 103 | return 104 | } 105 | if err := sessionManager.Add(ttsSession); err != nil { 106 | logger.PrintError(err) 107 | } 108 | return 109 | } 110 | 111 | // ignore case of "not join" or "include ignore prefix" 112 | ttsSession, err := sessionManager.GetByGuildID(m.GuildID) 113 | if errors.Is(err, session.ErrTtsSessionNotFound) { 114 | return 115 | } 116 | if err != nil { 117 | logger.PrintError(err) 118 | return 119 | } 120 | 121 | // Ignore if the TextChanelID of session and the channel of the message are different 122 | if ttsSession.TextChanelID != m.ChannelID { 123 | return 124 | } 125 | 126 | // other commands 127 | switch { 128 | case isCommandMessage(m.Content, "leave"): 129 | if err := ttsSession.Leave(discord); err != nil { 130 | logger.PrintError(err) 131 | } 132 | if err := sessionManager.Remove(ttsSession.GuildID()); err != nil { 133 | logger.PrintError(err) 134 | } 135 | return 136 | case isCommandMessage(m.Content, "speed"): 137 | speedStr := strings.Replace(m.Content, botName()+" speed ", "", 1) 138 | newSpeed, err := strconv.ParseFloat(speedStr, 64) 139 | if err != nil { 140 | ttsSession.SendMessagef(discord, "数字ではない値は設定できません") 141 | return 142 | } 143 | if err = ttsSession.SetSpeechSpeed(discord, newSpeed); err != nil { 144 | logger.PrintError(err) 145 | } 146 | return 147 | case isCommandMessage(m.Content, "lang"): 148 | newLang := strings.Replace(m.Content, botName()+" lang ", "", 1) 149 | if err = ttsSession.SetLanguage(discord, newLang); err != nil { 150 | logger.PrintError(err) 151 | } 152 | return 153 | case isCommandMessage(m.Content, "voice"): 154 | coefontID := strings.Replace(m.Content, botName()+" voice ", "", 1) 155 | ttsSession.SetCoefontID(coefontID) 156 | ttsSession.SendMessagef(discord, "声質を"+coefontID+"に変更しました") 157 | return 158 | } 159 | 160 | if err = ttsSession.Speech(discord, m.Content); err != nil { 161 | log.Println(err) 162 | } 163 | } 164 | 165 | func onVoiceStateUpdate(discord *discordgo.Session, v *discordgo.VoiceStateUpdate) { 166 | ttsSession, err := sessionManager.GetByGuildID(v.GuildID) 167 | if errors.Is(err, session.ErrTtsSessionNotFound) { 168 | return 169 | } 170 | if err != nil { 171 | log.Println(err) 172 | return 173 | } 174 | 175 | if !ttsSession.IsConnected() { 176 | return 177 | } 178 | 179 | // ボイスチャンネルに誰かしらいたら return 180 | for _, guild := range discord.State.Guilds { 181 | for _, vs := range guild.VoiceStates { 182 | if ttsSession.VoiceConnection.ChannelID == vs.ChannelID && vs.UserID != clientID { 183 | return 184 | } 185 | } 186 | } 187 | 188 | // ボイスチャンネルに誰もいなかったら Disconnect する 189 | if err := sessionManager.Remove(v.GuildID); err != nil { 190 | log.Println(err) 191 | } 192 | if err = ttsSession.Leave(discord); err != nil { 193 | log.Println(err) 194 | } 195 | } 196 | 197 | func isCommandMessage(message, command string) bool { 198 | return strings.HasPrefix(message, botName()+" "+command) 199 | } 200 | 201 | func sendMessagef(discord *discordgo.Session, textChanelID, format string, v ...interface{}) { 202 | session := session.NewTtsSession() 203 | session.TextChanelID = textChanelID 204 | session.SendMessagef(discord, format, v...) 205 | } 206 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/takanakahiko/discord-tts 2 | 3 | go 1.22 4 | 5 | toolchain go1.22.3 6 | 7 | // patch: https://github.com/takanakahiko-fork/dca/commit/7f9f6fb86b20cc6a4af1c95e4c442fba8e4f274d 8 | replace github.com/jonas747/dca v0.0.0-20201113050843-65838623978b => github.com/takanakahiko-fork/dca v0.0.0-20240125163404-7f9f6fb86b20 9 | 10 | require ( 11 | cloud.google.com/go/texttospeech v1.6.0 12 | github.com/bwmarrin/discordgo v0.27.1 13 | github.com/google/uuid v1.3.0 14 | github.com/jonas747/dca v0.0.0-20201113050843-65838623978b 15 | golang.org/x/text v0.14.0 16 | ) 17 | 18 | require ( 19 | cloud.google.com/go v0.110.0 // indirect 20 | cloud.google.com/go/compute v1.19.1 // indirect 21 | cloud.google.com/go/compute/metadata v0.2.3 // indirect 22 | cloud.google.com/go/longrunning v0.4.1 // indirect 23 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect 24 | github.com/golang/protobuf v1.5.3 // indirect 25 | github.com/google/go-cmp v0.5.9 // indirect 26 | github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect 27 | github.com/googleapis/gax-go/v2 v2.7.1 // indirect 28 | github.com/gorilla/websocket v1.5.1 // indirect 29 | github.com/jonas747/ogg v0.0.0-20161220051205-b4f6f4cf3757 // indirect 30 | go.opencensus.io v0.24.0 // indirect 31 | golang.org/x/crypto v0.18.0 // indirect 32 | golang.org/x/net v0.20.0 // indirect 33 | golang.org/x/oauth2 v0.7.0 // indirect 34 | golang.org/x/sys v0.16.0 // indirect 35 | google.golang.org/api v0.114.0 // indirect 36 | google.golang.org/appengine v1.6.7 // indirect 37 | google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect 38 | google.golang.org/grpc v1.56.3 // indirect 39 | google.golang.org/protobuf v1.30.0 // indirect 40 | ) 41 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | cloud.google.com/go v0.110.0 h1:Zc8gqp3+a9/Eyph2KDmcGaPtbKRIoqq4YTlL4NMD0Ys= 3 | cloud.google.com/go v0.110.0/go.mod h1:SJnCLqQ0FCFGSZMUNUf84MV3Aia54kn7pi8st7tMzaY= 4 | cloud.google.com/go/compute v1.19.1 h1:am86mquDUgjGNWxiGn+5PGLbmgiWXlE/yNWpIpNvuXY= 5 | cloud.google.com/go/compute v1.19.1/go.mod h1:6ylj3a05WF8leseCdIf77NK0g1ey+nj5IKd5/kvShxE= 6 | cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= 7 | cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= 8 | cloud.google.com/go/longrunning v0.4.1 h1:v+yFJOfKC3yZdY6ZUI933pIYdhyhV8S3NpWrXWmg7jM= 9 | cloud.google.com/go/longrunning v0.4.1/go.mod h1:4iWDqhBZ70CvZ6BfETbvam3T8FMvLK+eFj0E6AaRQTo= 10 | cloud.google.com/go/texttospeech v1.6.0 h1:H4g1ULStsbVtalbZGktyzXzw6jP26RjVGYx9RaYjBzc= 11 | cloud.google.com/go/texttospeech v1.6.0/go.mod h1:YmwmFT8pj1aBblQOI3TfKmwibnsfvhIBzPXcW4EBovc= 12 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 13 | github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY= 14 | github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= 15 | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 16 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 17 | github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= 18 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 19 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 20 | github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 21 | github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 22 | github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= 23 | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 24 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 25 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY= 26 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 27 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 28 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 29 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 30 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 31 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 32 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 33 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 34 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 35 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 36 | github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= 37 | github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 38 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 39 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 40 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 41 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 42 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 43 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 44 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 45 | github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 46 | github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 47 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 48 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 49 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 50 | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 51 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 52 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 53 | github.com/googleapis/enterprise-certificate-proxy v0.2.3 h1:yk9/cqRKtT9wXZSsRH9aurXEpJX+U6FLtpYTdC3R06k= 54 | github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k= 55 | github.com/googleapis/gax-go/v2 v2.7.1 h1:gF4c0zjUP2H/s/hEGyLA3I0fA2ZWjzYiONAD6cvPr8A= 56 | github.com/googleapis/gax-go/v2 v2.7.1/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI= 57 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 58 | github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= 59 | github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= 60 | github.com/jonas747/ogg v0.0.0-20161220051205-b4f6f4cf3757 h1:Kyv+zTfWIGRNaz/4+lS+CxvuKVZSKFz/6G8E3BKKBRs= 61 | github.com/jonas747/ogg v0.0.0-20161220051205-b4f6f4cf3757/go.mod h1:cZnNmdLiLpihzgIVqiaQppi9Ts3D4qF/M45//yW35nI= 62 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 63 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 64 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 65 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 66 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 67 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 68 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 69 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 70 | github.com/takanakahiko-fork/dca v0.0.0-20240125163404-7f9f6fb86b20 h1:wxbb+dcyv509cf5O+U0lXCC2p3ytUXiOarpL4XRKQCc= 71 | github.com/takanakahiko-fork/dca v0.0.0-20240125163404-7f9f6fb86b20/go.mod h1:KYVGWqPcwyWbu6TonlXU19bhwiqaiWr7MDpsZktnIw4= 72 | go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= 73 | go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= 74 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 75 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 76 | golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= 77 | golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= 78 | golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= 79 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 80 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 81 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 82 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 83 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 84 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 85 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 86 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 87 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 88 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 89 | golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 90 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 91 | golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= 92 | golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= 93 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 94 | golang.org/x/oauth2 v0.7.0 h1:qe6s0zUXlPX80/dITx3440hWZ7GwMwgDDyrSGTPJG/g= 95 | golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4= 96 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 97 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 98 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 99 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 100 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 101 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 102 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 103 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 104 | golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= 105 | golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 106 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 107 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 108 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 109 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 110 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 111 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 112 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 113 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 114 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 115 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 116 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 117 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 118 | google.golang.org/api v0.114.0 h1:1xQPji6cO2E2vLiI+C/XiFAnsn1WV3mjaEwGLhi3grE= 119 | google.golang.org/api v0.114.0/go.mod h1:ifYI2ZsFK6/uGddGfAD5BMxlnkBqCmqHSDUVi45N5Yg= 120 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 121 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 122 | google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= 123 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 124 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 125 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 126 | google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= 127 | google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A= 128 | google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU= 129 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 130 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 131 | google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= 132 | google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 133 | google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= 134 | google.golang.org/grpc v1.56.3 h1:8I4C0Yq1EjstUzUJzpcRVbuYA2mODtEmpWiQoN/b2nc= 135 | google.golang.org/grpc v1.56.3/go.mod h1:I9bI3vqKfayGqPUAwGdOSu7kt6oIJLixfffKrpXqQ9s= 136 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 137 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 138 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 139 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 140 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 141 | google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 142 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 143 | google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 144 | google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= 145 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 146 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 147 | google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= 148 | google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 149 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 150 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 151 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 152 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 153 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 154 | -------------------------------------------------------------------------------- /internal/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "log" 5 | "path/filepath" 6 | "runtime" 7 | ) 8 | 9 | func PrintError(err error) { 10 | _, file, line, ok := runtime.Caller(1) 11 | if ok { 12 | fname := filepath.Base(file) 13 | log.Printf(`%s:%d : %s\n`, fname, line, err.Error()) 14 | return 15 | } 16 | log.Printf(`%s\n`, err.Error()) 17 | } 18 | -------------------------------------------------------------------------------- /internal/session/manager.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | var ( 9 | errTtsSessionManager = errors.New("TtsSessionManager error") 10 | ErrTtsSessionNotFound = errors.New("ttsSession not found") 11 | ) 12 | 13 | type TtsSessionManager struct { 14 | sessions []*TtsSession 15 | } 16 | 17 | // NewTtsSession create new TtsSessionManager. 18 | func NewTtsSessionManager() *TtsSessionManager { 19 | return &TtsSessionManager{ 20 | sessions: []*TtsSession{}, 21 | } 22 | } 23 | 24 | // GetByGuildID. 25 | func (t *TtsSessionManager) GetByGuildID(guildID string) (*TtsSession, error) { 26 | for _, s := range t.sessions { 27 | if s.GuildID() == guildID { 28 | return s, nil 29 | } 30 | } 31 | return nil, ErrTtsSessionNotFound 32 | } 33 | 34 | // Add. 35 | func (t *TtsSessionManager) Add(ttsSession *TtsSession) error { 36 | _, err := t.GetByGuildID(ttsSession.GuildID()) 37 | if !errors.Is(err, ErrTtsSessionNotFound) { 38 | return fmt.Errorf("ttsSession is already in voice-chat: %w", errTtsSessionManager) 39 | } 40 | t.sessions = append(t.sessions, ttsSession) 41 | return nil 42 | } 43 | 44 | // Remove. 45 | func (t *TtsSessionManager) Remove(guildID string) error { 46 | ret := make([]*TtsSession, 0, len(t.sessions)-1) 47 | for _, v := range t.sessions { 48 | if v.GuildID() != guildID { 49 | ret = append(ret, v) 50 | } 51 | } 52 | t.sessions = ret 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /internal/session/session.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "log" 8 | "os" 9 | "regexp" 10 | "strconv" 11 | "sync" 12 | "time" 13 | 14 | "github.com/bwmarrin/discordgo" 15 | "github.com/jonas747/dca" 16 | "github.com/takanakahiko/discord-tts/internal/voice" 17 | "golang.org/x/text/language" 18 | ) 19 | 20 | const DefaultcontentID = "86fe0015-860a-409e-a79e-ff2d5dd818fd" 21 | 22 | var errTtsSession = errors.New("TtsSession error") 23 | 24 | // TtsSession is a data structure for managing bot agents that participate in one voice channel. 25 | type TtsSession struct { 26 | TextChanelID string 27 | VoiceConnection *discordgo.VoiceConnection 28 | mut sync.Mutex 29 | speechSpeed float64 30 | speechLanguage string 31 | guildID string 32 | coefontID string 33 | } 34 | 35 | // NewTtsSession create new TtsSession. 36 | func NewTtsSession() *TtsSession { 37 | return &TtsSession{ 38 | TextChanelID: "", 39 | VoiceConnection: nil, 40 | mut: sync.Mutex{}, 41 | speechSpeed: 1.5, //nolint:mnd // 直接指定した方がコードの可読性が高いため 42 | speechLanguage: "auto", 43 | guildID: "", 44 | coefontID: DefaultcontentID, 45 | } 46 | } 47 | 48 | // GetByGuildID. 49 | func (t *TtsSession) GuildID() string { 50 | return t.guildID 51 | } 52 | 53 | // Get state of VoiceConnection. 54 | func (t *TtsSession) IsConnected() bool { 55 | return t.VoiceConnection != nil && t.VoiceConnection.Ready 56 | } 57 | 58 | // Join join the same channel as the caller. 59 | func (t *TtsSession) Join(discord *discordgo.Session, callerUserID, textChannelID string) error { 60 | if t.VoiceConnection != nil { 61 | return fmt.Errorf("bot is already in voice-chat: %w", errTtsSession) 62 | } 63 | 64 | var callUserVoiceState *discordgo.VoiceState 65 | for _, guild := range discord.State.Guilds { 66 | for _, vs := range guild.VoiceStates { 67 | if vs.UserID == callerUserID { 68 | callUserVoiceState = vs 69 | } 70 | } 71 | } 72 | if callUserVoiceState == nil { 73 | t.SendMessagef(discord, "Caller is not in voice-chat.") 74 | return fmt.Errorf("caller is not in voice-chat: %w", errTtsSession) 75 | } 76 | 77 | voiceConnection, err := discord.ChannelVoiceJoin( 78 | callUserVoiceState.GuildID, callUserVoiceState.ChannelID, false, true) 79 | if err != nil { 80 | t.SendMessagef(discord, err.Error()) 81 | return fmt.Errorf( 82 | "failed ChannelVoiceJoin(gID=%s, cID=%s, mute=false, deaf=true): %w", 83 | callUserVoiceState.GuildID, callUserVoiceState.ChannelID, err) 84 | } 85 | t.VoiceConnection = voiceConnection 86 | t.TextChanelID = textChannelID 87 | t.guildID = voiceConnection.GuildID 88 | t.SendMessagef(discord, "Joined to voice chat!\n speechSpeed:%g\n speechLanguage:%s", t.speechSpeed, t.speechLanguage) 89 | return nil 90 | } 91 | 92 | // sendMessagef send text to text chat. 93 | func (t *TtsSession) SendMessagef(discord *discordgo.Session, format string, v ...interface{}) { 94 | if t.TextChanelID == "" { 95 | log.Println("Error sending message: TextChanelID is not set") 96 | } 97 | msg := fmt.Sprintf(format, v...) 98 | log.Println(">>> " + msg) 99 | if _, err := discord.ChannelMessageSend(t.TextChanelID, "[BOT] "+msg); err != nil { 100 | log.Println("Error sending message: ", err) 101 | } 102 | } 103 | 104 | // Speech speech the received text on the voice channel. 105 | func (t *TtsSession) Speech(discord *discordgo.Session, text string) error { 106 | if regexp.MustCompile(``).ReplaceAllString(text, "$1") 112 | text = regexp.MustCompile(`_`).ReplaceAllString(text, "") 113 | 114 | lang := t.speechLanguage 115 | if lang == "auto" { 116 | lang = "ja" 117 | if regexp.MustCompile(`^[a-zA-Z0-9\s.,]+$`).MatchString(text) { 118 | lang = "en" 119 | } 120 | } 121 | 122 | t.mut.Lock() 123 | defer t.mut.Unlock() 124 | 125 | voiceURL := t.FetchVoiceURL(text, lang) 126 | if voiceURL == "" { 127 | return nil 128 | } 129 | 130 | if err := t.playAudioFile(voiceURL); err != nil { 131 | t.SendMessagef(discord, "err=%s", err.Error()) 132 | return fmt.Errorf("t.playAudioFile(voiceURL:%+v) fail: %w", voiceURL, err) 133 | } 134 | return nil 135 | } 136 | 137 | // Leave end connection and init variables. 138 | func (t *TtsSession) Leave(discord *discordgo.Session) error { 139 | if err := t.VoiceConnection.Disconnect(); err != nil { 140 | return fmt.Errorf("t.VoiceConnection.Disconnect() fail: %w", err) 141 | } 142 | t.SendMessagef(discord, "Left from voice chat...") 143 | t.VoiceConnection = nil 144 | t.TextChanelID = "" 145 | return nil 146 | } 147 | 148 | // SetSpeechSpeed validate and set speechSpeed. 149 | func (t *TtsSession) SetSpeechSpeed(discord *discordgo.Session, newSpeechSpeed float64) error { 150 | if newSpeechSpeed < 0.5 || newSpeechSpeed > 100 { 151 | t.SendMessagef(discord, "You can set a value from 0.5 to 100") 152 | return fmt.Errorf("newSpeechSpeed=%v is invalid: %w", newSpeechSpeed, errTtsSession) 153 | } 154 | t.speechSpeed = newSpeechSpeed 155 | t.SendMessagef(discord, "Changed speed to %s", strconv.FormatFloat(newSpeechSpeed, 'f', -1, 64)) 156 | return nil 157 | } 158 | 159 | // SetLanguage. 160 | func (t *TtsSession) SetLanguage(discord *discordgo.Session, langText string) error { 161 | if langText == "auto" { 162 | t.speechLanguage = langText 163 | t.SendMessagef(discord, "Changed language to '%s'", t.speechLanguage) 164 | return nil 165 | } 166 | 167 | lang, err := language.Parse(langText) 168 | if err != nil { 169 | return fmt.Errorf("Language.Parse() fail: %w", err) 170 | } 171 | t.speechLanguage = lang.String() 172 | 173 | t.SendMessagef(discord, "Changed language to '%s'", t.speechLanguage) 174 | return nil 175 | } 176 | 177 | // SetCoefontID. 178 | func (t *TtsSession) SetCoefontID(coefontID string) { 179 | if coefontID == "default" { 180 | t.coefontID = DefaultcontentID 181 | return 182 | } 183 | 184 | t.coefontID = coefontID 185 | } 186 | 187 | // playAudioFile play audio file on the voice channel. 188 | func (t *TtsSession) playAudioFile(filename string) error { 189 | if err := t.VoiceConnection.Speaking(true); err != nil { 190 | return fmt.Errorf("t.VoiceConnection.Speaking(true) fail: %w", err) 191 | } 192 | defer func() { 193 | if err := t.VoiceConnection.Speaking(false); err != nil { 194 | log.Fatal(err) 195 | } 196 | }() 197 | 198 | opts := dca.StdEncodeOptions 199 | opts.CompressionLevel = 0 200 | opts.RawOutput = true 201 | opts.Bitrate = 120 202 | opts.AudioFilter = fmt.Sprintf("atempo=%f", t.speechSpeed) 203 | 204 | encodeSession, err := dca.EncodeFile(filename, opts) 205 | if err != nil { 206 | return fmt.Errorf("dca.EncodeFile(filename:%+v, opts:%+v) fail: %w", filename, opts, err) 207 | } 208 | 209 | done := make(chan error) 210 | stream := dca.NewStream(encodeSession, t.VoiceConnection, done) 211 | ticker := time.NewTicker(time.Second) 212 | 213 | for { 214 | select { 215 | case err := <-done: 216 | if err != nil && !errors.Is(err, io.EOF) { 217 | return err 218 | } 219 | os.Remove(filename) 220 | encodeSession.Cleanup() 221 | return nil 222 | case <-ticker.C: 223 | stats := encodeSession.Stats() 224 | playbackPosition := stream.PlaybackPosition() 225 | log.Printf( 226 | "Sending Now... : Playback: %10s, Transcode Stats: Time: %5s, Size: %5dkB, Bitrate: %6.2fkB, Speed: %5.1fx\r", 227 | playbackPosition, stats.Duration.String(), stats.Size, stats.Bitrate, stats.Speed) 228 | } 229 | } 230 | } 231 | 232 | func (t *TtsSession) FetchVoiceURL(text, lang string) string { 233 | return voice.NewGoogleTranslateAdapter(lang).FetchVoiceURL(text) 234 | } 235 | -------------------------------------------------------------------------------- /internal/voice/coefont_adapter.go: -------------------------------------------------------------------------------- 1 | package voice 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/hmac" 7 | "crypto/sha256" 8 | "encoding/hex" 9 | "encoding/json" 10 | "net/http" 11 | "os" 12 | "strconv" 13 | "time" 14 | 15 | "github.com/google/uuid" 16 | ) 17 | 18 | var _ Adapter = (*coefontAdapter)(nil) 19 | 20 | type coefontAdapter struct { 21 | CoefontID string 22 | } 23 | 24 | func NewCoefontAdapter(coefontID string) *coefontAdapter { 25 | return &coefontAdapter{CoefontID: coefontID} 26 | } 27 | 28 | type text2SpeechReq struct { 29 | CoefontID string `json:"coefont,omitempty"` 30 | Text string `json:"text,omitempty"` 31 | Speed float64 `json:"speed"` 32 | } 33 | 34 | func (a *coefontAdapter) FetchVoiceURL(text string) string { 35 | ctx := context.Background() 36 | 37 | accessKey := os.Getenv("COEFONT_ACCESS_TOKEN") 38 | secret := os.Getenv("COEFONT_SECRET") 39 | 40 | bytejson, err := json.Marshal(text2SpeechReq{ 41 | CoefontID: a.CoefontID, 42 | Text: text, 43 | Speed: 0.7, //nolint:mnd // 直接指定した方がコードの可読性が高いため 44 | }) 45 | if err != nil { 46 | return "" 47 | } 48 | stringtime := strconv.FormatInt(time.Now().Unix(), 10) 49 | sign := calcHMACSHA256(stringtime+string(bytejson), secret) 50 | 51 | client := &http.Client{ 52 | CheckRedirect: func(_ *http.Request, _ []*http.Request) error { 53 | return http.ErrUseLastResponse 54 | }, 55 | 56 | // 以下デフォルト値 57 | Transport: nil, 58 | Jar: nil, 59 | Timeout: 0, 60 | } 61 | 62 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, 63 | "https://api.coefont.cloud/v1/text2speech", bytes.NewBuffer(bytejson)) 64 | if err != nil { 65 | return "" 66 | } 67 | req.Header.Set("Content-Type", "application/json") 68 | req.Header.Set("X-Coefont-Content", sign) 69 | req.Header.Set("X-Coefont-Date", stringtime) 70 | req.Header.Set("Authorization", accessKey) 71 | resp, err := client.Do(req) 72 | if err != nil { 73 | return "" 74 | } 75 | defer resp.Body.Close() 76 | buf := new(bytes.Buffer) 77 | _, err = buf.ReadFrom(resp.Body) 78 | if err != nil { 79 | return "" 80 | } 81 | 82 | resp2, err := http.NewRequestWithContext(ctx, http.MethodGet, resp.Header.Get("Location"), nil) 83 | if err != nil { 84 | return "" 85 | } 86 | defer resp2.Body.Close() 87 | u, err := uuid.NewRandom() 88 | if err != nil { 89 | return "" 90 | } 91 | uu := u.String() 92 | path := uu + ".wav" 93 | audiofile, err := os.Create(path) 94 | if err != nil { 95 | return "" 96 | } 97 | defer audiofile.Close() 98 | _, err = buf.ReadFrom(resp.Body) 99 | if err != nil { 100 | return "" 101 | } 102 | _, err = audiofile.Write(buf.Bytes()) 103 | if err != nil { 104 | return "" 105 | } 106 | 107 | currentDirectory, _ := os.Getwd() 108 | return currentDirectory + "/" + path 109 | } 110 | 111 | func calcHMACSHA256(message, secret string) string { 112 | mac := hmac.New(sha256.New, []byte(secret)) 113 | _, _ = mac.Write([]byte(message)) 114 | return hex.EncodeToString(mac.Sum(nil)) 115 | } 116 | -------------------------------------------------------------------------------- /internal/voice/google_translate_adapter.go: -------------------------------------------------------------------------------- 1 | package voice 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | ) 7 | 8 | var _ Adapter = (*googleTranslateAdapter)(nil) 9 | 10 | // googleTranslateAdapter 11 | // 仮の実装として使っているが本来は利用しないほうがいい。 12 | type googleTranslateAdapter struct { 13 | Lang string 14 | } 15 | 16 | func NewGoogleTranslateAdapter(lang string) *googleTranslateAdapter { 17 | return &googleTranslateAdapter{Lang: lang} 18 | } 19 | 20 | func (a *googleTranslateAdapter) FetchVoiceURL(text string) string { 21 | return fmt.Sprintf( 22 | "http://translate.google.com/translate_tts?ie=UTF-8&textlen=32&client=tw-ob&q=%s&tl=%s", 23 | url.QueryEscape(text), a.Lang) 24 | } 25 | -------------------------------------------------------------------------------- /internal/voice/google_tts_adapter.go: -------------------------------------------------------------------------------- 1 | package voice 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | 8 | texttospeech "cloud.google.com/go/texttospeech/apiv1" 9 | "cloud.google.com/go/texttospeech/apiv1/texttospeechpb" 10 | ) 11 | 12 | var _ Adapter = (*googleTtsAdapter)(nil) 13 | 14 | // googleTtsAdapter. 15 | type googleTtsAdapter struct { 16 | LanguageCode string 17 | } 18 | 19 | func NewGoogleTtsAdapter(languageCode string) *googleTtsAdapter { 20 | return &googleTtsAdapter{ 21 | LanguageCode: languageCode, 22 | } 23 | } 24 | 25 | func (a *googleTtsAdapter) FetchVoiceURL(text string) string { 26 | ctx := context.Background() 27 | 28 | client, err := texttospeech.NewClient(ctx) 29 | if err != nil { 30 | log.Fatal(err) 31 | } 32 | defer client.Close() 33 | 34 | req := texttospeechpb.SynthesizeSpeechRequest{ 35 | Input: &texttospeechpb.SynthesisInput{ 36 | InputSource: &texttospeechpb.SynthesisInput_Text{Text: text}, //nolint:nosnakecase 37 | }, 38 | Voice: nil, 39 | AudioConfig: &texttospeechpb.AudioConfig{ 40 | AudioEncoding: texttospeechpb.AudioEncoding_MP3, //nolint:nosnakecase 41 | 42 | // 以下デフォルト値 43 | SpeakingRate: 0, 44 | Pitch: 0, 45 | VolumeGainDb: 0, 46 | SampleRateHertz: 0, 47 | EffectsProfileId: nil, 48 | }, 49 | } 50 | 51 | switch a.LanguageCode { 52 | case "ja-JP": 53 | req.Voice = &texttospeechpb.VoiceSelectionParams{ 54 | LanguageCode: a.LanguageCode, 55 | SsmlGender: texttospeechpb.SsmlVoiceGender_FEMALE, //nolint:nosnakecase 56 | Name: "ja-JP-Wavenet-B", 57 | CustomVoice: nil, 58 | } 59 | case "en-US": 60 | req.Voice = &texttospeechpb.VoiceSelectionParams{ 61 | LanguageCode: a.LanguageCode, 62 | SsmlGender: texttospeechpb.SsmlVoiceGender_FEMALE, //nolint:nosnakecase 63 | Name: "en-US-Wavenet-C", 64 | CustomVoice: nil, 65 | } 66 | } 67 | 68 | resp, err := client.SynthesizeSpeech(ctx, &req) 69 | if err != nil { 70 | log.Panic(err) 71 | } 72 | 73 | tmpfile, err := os.CreateTemp("", "discord-tts_google-tts-adapter_*.mp3") 74 | if err != nil { 75 | log.Panic(err) 76 | } 77 | defer tmpfile.Close() 78 | err = os.WriteFile(tmpfile.Name(), resp.GetAudioContent(), 0600) //nolint:gofumpt,mnd // fs.FileModeは直接指定した方がわかりやすいため 79 | 80 | if err != nil { 81 | log.Panic(err) 82 | } 83 | log.Printf("Audio content written to file: %v\n", tmpfile.Name()) 84 | return tmpfile.Name() 85 | } 86 | -------------------------------------------------------------------------------- /internal/voice/voice_adapter.go: -------------------------------------------------------------------------------- 1 | package voice 2 | 3 | type Adapter interface { 4 | FetchVoiceURL(text string) string 5 | } 6 | -------------------------------------------------------------------------------- /sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/takanakahiko/discord-tts/2195764ba46d1d6b251cea4f29b0cd9e6839d824/sample.png --------------------------------------------------------------------------------