├── .github └── workflows │ └── build_release_by_version_tag.yml ├── .gitignore ├── LICENSE ├── README.md ├── README_en-US.md ├── edge_tts ├── communicate.go ├── constants.go ├── drm.go ├── eorrors.go └── list_voices.go ├── go.mod ├── go.sum ├── internal └── cmd │ ├── root.go │ └── utils.go └── main.go /.github/workflows/build_release_by_version_tag.yml: -------------------------------------------------------------------------------- 1 | name: edge-tts-go Build Release By Version Tag 2 | 3 | on: 4 | push: 5 | # Sequence of patterns matched against refs/tags 6 | tags: 7 | - '0.*' # Push events to matching 0.*, i.e. 0.1.0, 0.1.1 8 | 9 | env: 10 | TZ: Asia/Shanghai 11 | 12 | jobs: 13 | build: 14 | name: Build And Release 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Get git repo name 18 | id: get_repo_name 19 | run: echo "REPO_NAME=${GITHUB_REPOSITORY#$GITHUB_REPOSITORY_OWNER/}" >> $GITHUB_OUTPUT 20 | 21 | - name: Get git tag version 22 | id: get_tag_version 23 | run: echo "VERSION=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT 24 | 25 | - name: Checkout Github Code 26 | uses: actions/checkout@v3 27 | 28 | - name: Set Up Golang Environment 29 | uses: actions/setup-go@v3 30 | with: 31 | go-version: 1.21 32 | 33 | - name: Build CLI Binary 34 | run: | 35 | for goOs in linux windows darwin;\ 36 | do echo "Building ${goOs} amd64 binary...";\ 37 | outputFile="${{ steps.get_repo_name.outputs.REPO_NAME }}";\ 38 | if [[ ${goOs} =~ 'windows' ]];\ 39 | then outputFile="${{ steps.get_repo_name.outputs.REPO_NAME }}.exe";\ 40 | fi;\ 41 | goArch=amd64 42 | GOOS=$goOs GOARCH=$goArch go build -o $outputFile;\ 43 | tar -zcvf ${{ steps.get_repo_name.outputs.REPO_NAME }}-${{ steps.get_tag_version.outputs.VERSION }}-${goOs}-${goArch}.tar.gz ${outputFile};\ 44 | rm ${outputFile};\ 45 | done 46 | 47 | - name: Create Github Release 48 | id: create_release 49 | uses: softprops/action-gh-release@v2 50 | env: 51 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 | with: 53 | name: "${{ steps.get_repo_name.outputs.REPO_NAME }} Release ${{ steps.get_tag_version.outputs.VERSION }}" 54 | tag_name: ${{ github.ref }} 55 | draft: false 56 | prerelease: false 57 | 58 | - name: Upload Release Asset 59 | id: upload-release-asset 60 | uses: alexellis/upload-assets@0.2.2 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | with: 64 | asset_paths: '["*.tar.gz"]' 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | 23 | # editor 24 | .idea 25 | 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 wujunwei928 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # edge-tts-go 2 | 3 | * [中文](https://github.com/wujunwei928/edge-tts-go/blob/main/README.md) 4 | * [English](https://github.com/wujunwei928/edge-tts-go/blob/main/README_en-US.md) 5 | 6 | `edge-tts-go` 是一个 golang 模块,允许您从 golang 代码中或使用提供的 `edge-tts-go` 命令使用 Microsoft Edge 的在线文本到语音服务。 7 | 8 | ## 安装 9 | 10 | ### go install 11 | $ go install github.com/wujunwei928/edge-tts-go 12 | 13 | ### 下载预编译版本 14 | https://github.com/wujunwei928/edge-tts-go/releases 15 | 16 | ## 用法 17 | 18 | ### 基本用法 19 | 20 | 如果您想使用 `edge-tts-go` 命令,只需使用以下命令运行它: 21 | 22 | $ edge-tts-go --text "Hello, world" --write-media hello.mp3 23 | 24 | ### 改变声音 25 | 26 | 如果您想更改转换文本时使用的声音。 27 | 28 | 您需要使用 `--list-voices` 选项检查可用的语音: 29 | 30 | $ edge-tts-go --list-voices 31 | Name: Microsoft Server Speech Text to Speech Voice (zh-CN, XiaoxiaoNeural) 32 | ShortName: zh-CN-XiaoxiaoNeural 33 | Gender: Female 34 | Locale: zh-CN 35 | ContentCategories: News,Novel 36 | VoicePersonalities: Warm 37 | 38 | Name: Microsoft Server Speech Text to Speech Voice (zh-CN, XiaoyiNeural) 39 | ShortName: zh-CN-XiaoyiNeural 40 | Gender: Female 41 | Locale: zh-CN 42 | ContentCategories: Cartoon,Novel 43 | VoicePersonalities: Lively 44 | 45 | ... 46 | 47 | 使用 `--voice` 选项指定声音进行转换 48 | 49 | $ edge-tts-go --voice zh-CN-XiaoxiaoNeural --text "纵使浮云蔽天日,我亦拔剑破长空" --write-media hello_in_chinese.mp3 50 | 51 | 如果你的电脑安装过ffplay,你可以使用以下命令直接播放音频文件: 52 | $ edge-tts-go --voice zh-CN-XiaoxiaoNeural --text "纵使浮云蔽天日,我亦拔剑破长空" | ffplay -i - 53 | 54 | ### 改变速率、音量和音调 55 | 56 | $ edge-tts-go --rate=-50% --text "Hello, world" --write-media hello_with_rate_halved.mp3 57 | $ edge-tts-go --volume=-50% --text "Hello, world" --write-media hello_with_volume_halved.mp3 58 | $ edge-tts-go --pitch=-50Hz --text "Hello, world" --write-media hello_with_pitch_halved.mp3 59 | 60 | ## go 模块 61 | 62 | 可以直接在go代码中使用 `edge-tts-go` 模块, 从下面的文件查看调用方法: 63 | 64 | * https://github.com/wujunwei928/edge-tts-go/blob/main/internal/cmd/root.go 65 | 66 | ## 致谢 67 | 68 | * https://github.com/rany2/edge-tts 69 | -------------------------------------------------------------------------------- /README_en-US.md: -------------------------------------------------------------------------------- 1 | # edge-tts-go 2 | 3 | * [中文](https://github.com/wujunwei928/edge-tts-go/blob/main/README.md) 4 | * [English](https://github.com/wujunwei928/edge-tts-go/blob/main/README_en-US.md) 5 | 6 | `edge-tts-go` is a golang module that allows you to use Microsoft Edge's online text-to-speech service from within your golang code or using the provided `edge-tts-go` command. 7 | 8 | ## Installation 9 | 10 | ### go install 11 | To install it, run the following command: 12 | 13 | $ go install github.com/wujunwei928/edge-tts-go 14 | 15 | ### download release 16 | https://github.com/wujunwei928/edge-tts-go/releases 17 | 18 | ## Usage 19 | 20 | ### Basic usage 21 | 22 | If you want to use the `edge-tts-go` command, you can simply run it with the following command: 23 | 24 | $ edge-tts-go --text "Hello, world" --write-media hello.mp3 25 | 26 | ### Changing the voice 27 | 28 | If you want to change the language of the speech or more generally, the voice. 29 | 30 | You must first check the available voices with the `--list-voices` option: 31 | 32 | $ edge-tts-go --list-voices 33 | Name: Microsoft Server Speech Text to Speech Voice (zh-CN, XiaoxiaoNeural) 34 | ShortName: zh-CN-XiaoxiaoNeural 35 | Gender: Female 36 | Locale: zh-CN 37 | ContentCategories: News,Novel 38 | VoicePersonalities: Warm 39 | 40 | Name: Microsoft Server Speech Text to Speech Voice (zh-CN, XiaoyiNeural) 41 | ShortName: zh-CN-XiaoyiNeural 42 | Gender: Female 43 | Locale: zh-CN 44 | ContentCategories: Cartoon,Novel 45 | VoicePersonalities: Lively 46 | 47 | ... 48 | 49 | $ edge-tts-go --voice zh-CN-XiaoxiaoNeural --text "纵使浮云蔽天日,我亦拔剑破长空" --write-media hello_in_chinese.mp3 50 | 51 | if you already install ffplay in your computer, you can play the audio file with the following command: 52 | $ edge-tts-go --voice zh-CN-XiaoxiaoNeural --text "纵使浮云蔽天日,我亦拔剑破长空" | ffplay -i - 53 | ### Changing rate, volume and pitch 54 | 55 | It is possible to make minor changes to the generated speech. 56 | 57 | $ edge-tts-go --rate=-50% --text "Hello, world" --write-media hello_with_rate_halved.mp3 58 | $ edge-tts-go --volume=-50% --text "Hello, world" --write-media hello_with_volume_halved.mp3 59 | $ edge-tts-go --pitch=-50Hz --text "Hello, world" --write-media hello_with_pitch_halved.mp3 60 | 61 | ## go module 62 | 63 | It is possible to use the `edge-tts-go` module directly from go. look the following file: 64 | 65 | * https://github.com/wujunwei928/edge-tts-go/blob/main/internal/cmd/root.go 66 | 67 | ## thanks 68 | 69 | * https://github.com/rany2/edge-tts 70 | -------------------------------------------------------------------------------- /edge_tts/communicate.go: -------------------------------------------------------------------------------- 1 | package edge_tts 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/binary" 7 | "errors" 8 | "fmt" 9 | "log" 10 | "net/http" 11 | "net/url" 12 | "regexp" 13 | "strings" 14 | "time" 15 | "unicode/utf8" 16 | 17 | "github.com/google/uuid" 18 | "github.com/gorilla/websocket" 19 | ) 20 | 21 | // Communicate is a struct representing communication with the service. 22 | type Communicate struct { 23 | text string 24 | voice string 25 | rate string 26 | volume string 27 | pitch string 28 | proxy string 29 | receiveTimeout int 30 | } 31 | 32 | type CommunicateOption func(*Communicate) error 33 | 34 | // getHeadersAndData returns the headers and data from the given data. 35 | func getHeadersAndData(data interface{}) (map[string][]byte, []byte, error) { 36 | var dataBytes []byte 37 | switch v := data.(type) { 38 | case string: 39 | dataBytes = []byte(v) 40 | case []byte: 41 | dataBytes = v 42 | default: 43 | return nil, nil, errors.New("data must be string or []byte") 44 | } 45 | 46 | headers := make(map[string][]byte) 47 | headerEnd := bytes.Index(dataBytes, []byte("\r\n\r\n")) 48 | if headerEnd == -1 { 49 | return nil, nil, errors.New("invalid data format: no header end") 50 | } 51 | 52 | headerLines := bytes.Split(dataBytes[:headerEnd], []byte("\r\n")) 53 | for _, line := range headerLines { 54 | parts := bytes.SplitN(line, []byte(":"), 2) 55 | if len(parts) != 2 { 56 | return nil, nil, errors.New("invalid header format") 57 | } 58 | key := string(bytes.TrimSpace(parts[0])) 59 | value := bytes.TrimSpace(parts[1]) 60 | headers[key] = value 61 | } 62 | 63 | return headers, dataBytes[headerEnd+4:], nil 64 | } 65 | 66 | // removeIncompatibleCharacters removes incompatible characters from the string. 67 | func removeIncompatibleCharacters(input interface{}) (string, error) { 68 | var str string 69 | switch v := input.(type) { 70 | case string: 71 | str = v 72 | case []byte: 73 | str = string(v) 74 | default: 75 | return "", errors.New("input must be string or []byte") 76 | } 77 | 78 | var cleanedStr bytes.Buffer 79 | for _, char := range str { 80 | code := int(char) 81 | if (0 <= code && code <= 8) || (11 <= code && code <= 12) || (14 <= code && code <= 31) { 82 | cleanedStr.WriteRune(' ') 83 | } else { 84 | cleanedStr.WriteRune(char) 85 | } 86 | } 87 | 88 | return cleanedStr.String(), nil 89 | } 90 | 91 | // connectID generates a UUID without dashes. 92 | func connectID() string { 93 | u := uuid.New() 94 | return strings.ReplaceAll(u.String(), "-", "") 95 | } 96 | 97 | // splitTextByByteLength splits a string into a list of strings of a given byte length 98 | // while attempting to keep words together. 99 | func splitTextByByteLength(text []byte, byteLength int) [][]byte { 100 | var result [][]byte 101 | 102 | if byteLength <= 0 { 103 | panic("byteLength must be greater than 0") 104 | } 105 | 106 | for len(text) > byteLength { 107 | // Find the last space in the string within the byte length 108 | splitAt := byteLength 109 | for i := byteLength; i > 0; i-- { 110 | if utf8.RuneStart(text[i]) { 111 | splitAt = i 112 | break 113 | } 114 | } 115 | 116 | // Verify all & are terminated with a ; 117 | for bytes.Contains(text[:splitAt], []byte("&")) { 118 | ampersandIndex := bytes.LastIndex(text[:splitAt], []byte("&")) 119 | if bytes.Index(text[ampersandIndex:splitAt], []byte(";")) != -1 { 120 | break 121 | } 122 | 123 | splitAt = ampersandIndex - 1 124 | if splitAt < 0 { 125 | panic("Maximum byte length is too small or invalid text") 126 | } 127 | if splitAt == 0 { 128 | break 129 | } 130 | } 131 | 132 | // Append the string to the list 133 | newText := bytes.TrimSpace(text[:splitAt]) 134 | if len(newText) > 0 { 135 | result = append(result, newText) 136 | } 137 | 138 | text = text[splitAt:] 139 | } 140 | 141 | newText := bytes.TrimSpace(text) 142 | if len(newText) > 0 { 143 | result = append(result, newText) 144 | } 145 | 146 | return result 147 | } 148 | 149 | // mkSSML creates an SSML string from the given parameters. 150 | func mkSSML(text string, voice string, rate string, volume string, pitch string) string { 151 | ssml := fmt.Sprintf( 152 | ""+ 153 | "%s", voice, pitch, rate, volume, text) 154 | return ssml 155 | } 156 | 157 | // dateToString returns a JavaScript-style date string. 158 | func dateToString() string { 159 | utcTime := time.Now().UTC() 160 | timeString := utcTime.Format("Mon Jan 02 2006 15:04:05 GMT+0000 (Coordinated Universal Time)") 161 | return timeString 162 | } 163 | 164 | // ssmlHeadersPlusData returns the headers and data to be used in the request. 165 | func ssmlHeadersPlusData(requestID string, timestamp string, ssml string) string { 166 | headersAndData := fmt.Sprintf( 167 | "X-RequestId:%s\r\n"+ 168 | "Content-Type:application/ssml+xml\r\n"+ 169 | "X-Timestamp:%sZ\r\n"+ 170 | "Path:ssml\r\n\r\n"+ 171 | "%s", 172 | requestID, timestamp, ssml) 173 | return headersAndData 174 | } 175 | 176 | // calcMaxMesgSize calculates the maximum message size for the given voice, rate, and volume. 177 | func calcMaxMesgSize(voice string, rate string, volume string, pitch string) int { 178 | websocketMaxSize := 1 << 16 179 | // Calculate overhead per message 180 | overheadPerMessage := len(ssmlHeadersPlusData(connectID(), dateToString(), mkSSML("", voice, rate, volume, pitch))) + 50 // margin of error 181 | return websocketMaxSize - overheadPerMessage 182 | } 183 | 184 | // ValidateStringParam validates the given string parameter based on type and pattern. 185 | func ValidateStringParam(paramName, paramValue, pattern string) (string, error) { 186 | if len(paramValue) == 0 { 187 | return "", errors.New(fmt.Sprintf("%s不能为空", paramName)) 188 | } 189 | match, err := regexp.MatchString(pattern, paramValue) 190 | if err != nil { 191 | return "", err 192 | } 193 | if !match { 194 | return "", errors.New(fmt.Sprintf("%s不符合模式%s", paramName, pattern)) 195 | } 196 | return paramValue, nil 197 | } 198 | 199 | // NewCommunicate initializes the Communicate struct. 200 | func NewCommunicate(text string, options ...CommunicateOption) (*Communicate, error) { 201 | c := &Communicate{ 202 | text: text, 203 | voice: "Microsoft Server Speech Text to Speech Voice (en-US, AriaNeural)", 204 | rate: "+0%", 205 | volume: "+0%", 206 | pitch: "+0Hz", 207 | proxy: "", 208 | receiveTimeout: 10, 209 | } 210 | 211 | for _, option := range options { 212 | err := option(c) 213 | if err != nil { 214 | return nil, err 215 | } 216 | } 217 | 218 | return c, nil 219 | } 220 | 221 | // SetVoice sets the voice for communication. 222 | func SetVoice(voice string) CommunicateOption { 223 | return func(c *Communicate) error { 224 | //var err error 225 | //c.voice, err = ValidateStringParam("voice", voice, `^Microsoft Server Speech Text to Speech Voice \(.+,.+\)$`) 226 | //return err 227 | c.voice = voice 228 | return nil 229 | } 230 | } 231 | 232 | // SetRate sets the rate for communication. 233 | func SetRate(rate string) CommunicateOption { 234 | return func(c *Communicate) error { 235 | var err error 236 | c.rate, err = ValidateStringParam("rate", rate, `^[+-]\d+%$`) 237 | return err 238 | } 239 | } 240 | 241 | // SetVolume sets the volume for communication. 242 | func SetVolume(volume string) CommunicateOption { 243 | return func(c *Communicate) error { 244 | var err error 245 | c.volume, err = ValidateStringParam("volume", volume, `^[+-]\d+%$`) 246 | return err 247 | } 248 | } 249 | 250 | // SetPitch sets the pitch for communication. 251 | func SetPitch(pitch string) CommunicateOption { 252 | return func(c *Communicate) error { 253 | var err error 254 | c.pitch, err = ValidateStringParam("pitch", pitch, `^[+-]\d+(Hz|%)$`) 255 | return err 256 | } 257 | } 258 | 259 | // SetProxy sets the proxy for communication. 260 | func SetProxy(proxy string) CommunicateOption { 261 | return func(c *Communicate) error { 262 | c.proxy = proxy 263 | return nil 264 | } 265 | } 266 | 267 | // SetReceiveTimeout sets the receive timeout for communication. 268 | func SetReceiveTimeout(receiveTimeout int) CommunicateOption { 269 | return func(c *Communicate) error { 270 | c.receiveTimeout = receiveTimeout 271 | return nil 272 | } 273 | } 274 | 275 | func (c *Communicate) newWebSocketConn() (*websocket.Conn, error) { 276 | dialer := &websocket.Dialer{ 277 | Proxy: http.ProxyFromEnvironment, 278 | HandshakeTimeout: 45 * time.Second, 279 | EnableCompression: true, 280 | } 281 | 282 | if len(c.proxy) > 0 { 283 | proxyURL, err := url.Parse(c.proxy) 284 | if err != nil { 285 | return nil, err 286 | } 287 | dialer.Proxy = http.ProxyURL(proxyURL) 288 | } 289 | 290 | header := http.Header{} 291 | for k, v := range WSS_HEADERS { 292 | header.Set(k, v) 293 | } 294 | 295 | dialCtx, dialContextCancel := context.WithTimeout(context.Background(), time.Duration(c.receiveTimeout)*time.Second) 296 | defer func() { 297 | dialContextCancel() 298 | }() 299 | 300 | reqUrl := fmt.Sprintf("%s&Sec-MS-GEC=%s&Sec-MS-GEC-Version=%s&ConnectionId=%s", WSS_URL, GenerateSecMSGec(), SEC_MS_GEC_VERSION, connectID()) 301 | conn, _, err := dialer.DialContext(dialCtx, reqUrl, header) 302 | if err != nil { 303 | return nil, err 304 | } 305 | 306 | return conn, nil 307 | } 308 | 309 | func (c *Communicate) Stream() ([]byte, error) { 310 | conn, err := c.newWebSocketConn() 311 | if err != nil { 312 | return nil, err 313 | } 314 | defer conn.Close() 315 | 316 | var finished = make(chan struct{}) 317 | var failed = make(chan error) 318 | audioData := make([]byte, 0) 319 | go func() { 320 | defer func() { 321 | close(finished) 322 | close(failed) 323 | }() 324 | for { 325 | receivedType, receivedData, receivedErr := conn.ReadMessage() 326 | if receivedType == -1 && receivedData == nil && receivedErr != nil { //已经断开链接 327 | failed <- receivedErr 328 | return 329 | } 330 | 331 | switch receivedType { 332 | case websocket.TextMessage: 333 | textHeader, _, textErr := getHeadersAndData(receivedData) 334 | if textErr != nil { 335 | failed <- textErr 336 | return 337 | } 338 | if string(textHeader["Path"]) == "turn.end" { 339 | return 340 | } 341 | case websocket.BinaryMessage: 342 | if len(receivedData) < 2 { 343 | failed <- errors.New("we received a binary message, but it is missing the header length") 344 | return 345 | } 346 | 347 | headerLength := binary.BigEndian.Uint16(receivedData[:2]) 348 | if len(receivedData) < int(headerLength+2) { 349 | failed <- errors.New("we received a binary message, but it is missing the audio data") 350 | return 351 | } 352 | 353 | audioData = append(audioData, receivedData[2+headerLength:]...) 354 | default: 355 | log.Println("recv:", receivedData) 356 | } 357 | } 358 | }() 359 | 360 | err = conn.WriteMessage(websocket.TextMessage, []byte(c.getCommandRequestContent())) 361 | if err != nil { 362 | return nil, err 363 | } 364 | 365 | err = conn.WriteMessage(websocket.TextMessage, []byte(c.getSSMLRequestContent())) 366 | if err != nil { 367 | return nil, err 368 | } 369 | 370 | select { 371 | case <-finished: 372 | return audioData, err 373 | case errMessage := <-failed: 374 | return nil, errMessage 375 | } 376 | } 377 | 378 | func (c *Communicate) getCommandRequestContent() string { 379 | var builder strings.Builder 380 | 381 | // 拼接X-Timestamp部分 382 | builder.WriteString(fmt.Sprintf("X-Timestamp:%s\r\n", dateToString())) 383 | 384 | // 拼接Content-Type部分 385 | builder.WriteString("Content-Type:application/json; charset=utf-8\r\n") 386 | 387 | // 拼接Path部分 388 | builder.WriteString("Path:speech.config\r\n\r\n") 389 | 390 | // 拼接JSON部分 391 | builder.WriteString(`{"context":{"synthesis":{"audio":{"metadataoptions":{`) 392 | builder.WriteString(`"sentenceBoundaryEnabled":"false","wordBoundaryEnabled":"true"},`) 393 | builder.WriteString(`"outputFormat":"audio-24khz-48kbitrate-mono-mp3"`) 394 | builder.WriteString("}}}}\r\n") 395 | 396 | return builder.String() 397 | } 398 | 399 | func (c *Communicate) getSSMLRequestContent() string { 400 | return ssmlHeadersPlusData( 401 | connectID(), 402 | dateToString(), 403 | mkSSML(c.text, c.voice, c.rate, c.volume, c.pitch), 404 | ) 405 | } 406 | -------------------------------------------------------------------------------- /edge_tts/constants.go: -------------------------------------------------------------------------------- 1 | package edge_tts 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | const ( 9 | PackageVersion = "0.0.1" // edge-tts-go 包版本 10 | ) 11 | 12 | // edge tts 相关接口 13 | const ( 14 | TRUSTED_CLIENT_TOKEN = "6A5AA1D4EAFF4E9FB37E23D68491D6F4" 15 | WSS_URL = "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1?TrustedClientToken=" + TRUSTED_CLIENT_TOKEN 16 | VOICE_LIST_URL = "https://speech.platform.bing.com/consumer/speech/synthesize/readaloud/voices/list?trustedclienttoken=" + TRUSTED_CLIENT_TOKEN 17 | ) 18 | 19 | const CHROMIUM_FULL_VERSION = "134.0.3124.66" 20 | 21 | var ( 22 | CHROMIUM_MAJOR_VERSION = strings.SplitN(CHROMIUM_FULL_VERSION, ".", 2)[0] 23 | SEC_MS_GEC_VERSION = fmt.Sprintf("1-%s", CHROMIUM_FULL_VERSION) 24 | 25 | BASE_HEADERS = map[string]string{ 26 | "User-Agent": fmt.Sprintf("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s.0.0.0 Safari/537.36 Edg/%s.0.0.0", CHROMIUM_MAJOR_VERSION, CHROMIUM_MAJOR_VERSION), 27 | "Accept-Encoding": "gzip, deflate, br", 28 | "Accept-Language": "en-US,en;q=0.9", 29 | } 30 | 31 | WSS_HEADERS = MergeMap(map[string]string{ 32 | "Pragma": "no-cache", 33 | "Cache-Control": "no-cache", 34 | "Origin": "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold", 35 | }, BASE_HEADERS) 36 | 37 | VOICE_HEADERS = MergeMap(map[string]string{ 38 | "Authority": "speech.platform.bing.com", 39 | "Sec-CH-UA": fmt.Sprintf(`" Not;A Brand";v="99", "Microsoft Edge";v="%s", "Chromium";v="%s"`, CHROMIUM_MAJOR_VERSION, CHROMIUM_MAJOR_VERSION), 40 | "Sec-CH-UA-Mobile": "?0", 41 | "Accept": "*/*", 42 | "Sec-Fetch-Site": "none", 43 | "Sec-Fetch-Mode": "cors", 44 | "Sec-Fetch-Dest": "empty", 45 | }, BASE_HEADERS) 46 | ) 47 | 48 | func MergeMap(m1, m2 map[string]string) map[string]string { 49 | for k, v := range m2 { 50 | m1[k] = v 51 | } 52 | return m1 53 | } 54 | -------------------------------------------------------------------------------- /edge_tts/drm.go: -------------------------------------------------------------------------------- 1 | package edge_tts 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "strings" 10 | "sync" 11 | "time" 12 | ) 13 | 14 | const ( 15 | winEpoch = 11644473600 // Windows epoch (1601-01-01 00:00:00 UTC) 16 | sToNs = 1e9 17 | ) 18 | 19 | var ( 20 | clockSkewSeconds float64 21 | skewMutex sync.Mutex 22 | ) 23 | 24 | // AdjustClockSkew 调整时钟偏差(线程安全) 25 | func AdjustClockSkew(skewSeconds float64) { 26 | skewMutex.Lock() 27 | defer skewMutex.Unlock() 28 | clockSkewSeconds += skewSeconds 29 | } 30 | 31 | // GetUnixTimestamp 获取当前Unix时间戳(含时钟偏差校正) 32 | func GetUnixTimestamp() float64 { 33 | return float64(time.Now().UTC().UnixNano())/1e9 + clockSkewSeconds 34 | } 35 | 36 | // ParseRFC2616Date 解析RFC 2616日期字符串 37 | func ParseRFC2616Date(date string) (float64, error) { 38 | t, err := time.ParseInLocation(time.RFC1123, date, time.UTC) 39 | if err != nil { 40 | return 0, err 41 | } 42 | return float64(t.UnixNano()) / 1e9, nil 43 | } 44 | 45 | // HandleClientResponseError 处理客户端响应错误 46 | func HandleClientResponseError(resp *http.Response) error { 47 | serverDate := resp.Header.Get("Date") 48 | if serverDate == "" { 49 | return errors.New("no server date in headers") 50 | } 51 | 52 | serverTime, err := ParseRFC2616Date(serverDate) 53 | if err != nil { 54 | return fmt.Errorf("failed to parse server date: %w", err) 55 | } 56 | 57 | clientTime := GetUnixTimestamp() 58 | AdjustClockSkew(serverTime - clientTime) 59 | return nil 60 | } 61 | 62 | // GenerateSecMSGec 生成Sec-MS-GEC令牌 63 | func GenerateSecMSGec() string { 64 | // 获取校正后的时间戳 65 | ticks := GetUnixTimestamp() 66 | 67 | // 转换为Windows文件时间基准 68 | ticks += winEpoch 69 | 70 | // 向下取整到最近的5分钟(300秒) 71 | ticks -= float64(int64(ticks) % 300) 72 | 73 | // 转换为100纳秒间隔 74 | ticks *= sToNs / 100 75 | 76 | // 生成哈希字符串, 将 ticks 保留9位整数,后面都为0 77 | strToHash := fmt.Sprintf("%d%s", int(ticks/1e9)*1e9, TRUSTED_CLIENT_TOKEN) 78 | hash := sha256.Sum256([]byte(strToHash)) 79 | return strings.ToUpper(hex.EncodeToString(hash[:])) 80 | } 81 | -------------------------------------------------------------------------------- /edge_tts/eorrors.go: -------------------------------------------------------------------------------- 1 | package edge_tts 2 | 3 | import "errors" 4 | 5 | // Errors for the Edge TTS project. 6 | 7 | // UnknownResponse error for unknown server responses 8 | var UnknownResponse = errors.New("unknown response received from the server") 9 | 10 | // UnexpectedResponse error for unexpected server responses 11 | // 12 | // This hasn't happened yet, but it's possible that the server will change its response format in the future. 13 | var UnexpectedResponse = errors.New("unexpected response received from the server") 14 | 15 | // NoAudioReceived error when no audio is received from the server 16 | var NoAudioReceived = errors.New("no audio received from the server") 17 | 18 | // WebSocketError error for WebSocket errors 19 | var WebSocketError = errors.New("WebSocket error occurred") 20 | -------------------------------------------------------------------------------- /edge_tts/list_voices.go: -------------------------------------------------------------------------------- 1 | package edge_tts 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/go-resty/resty/v2" 8 | ) 9 | 10 | type Voice struct { 11 | Name string `json:"Name"` 12 | ShortName string `json:"ShortName"` 13 | Gender string `json:"Gender"` 14 | Locale string `json:"Locale"` 15 | SuggestedCodec string `json:"SuggestedCodec"` 16 | FriendlyName string `json:"FriendlyName"` 17 | Status string `json:"Status"` 18 | VoiceTag VoiceTag `json:"VoiceTag"` 19 | } 20 | 21 | type VoiceTag struct { 22 | ContentCategories []string `json:"ContentCategories"` 23 | VoicePersonalities []string `json:"VoicePersonalities"` 24 | } 25 | 26 | func ListVoices(proxyURL string) ([]Voice, error) { 27 | client := resty.New() 28 | if len(proxyURL) > 0 { 29 | client.SetProxy(proxyURL) 30 | } 31 | 32 | voiceListUrl := fmt.Sprintf( 33 | "%s&Sec-MS-GEC=%s&Sec-MS-GEC-Version=%s", 34 | VOICE_LIST_URL, 35 | GenerateSecMSGec(), 36 | SEC_MS_GEC_VERSION, 37 | ) 38 | 39 | resp, err := client.R(). 40 | EnableTrace(). 41 | SetHeaders(VOICE_HEADERS). 42 | Get(voiceListUrl) 43 | 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | if resp.StatusCode() != 200 { 49 | return nil, fmt.Errorf("failed to list voices, http status code: %s", resp.Status()) 50 | } 51 | 52 | var voices []Voice 53 | err = json.Unmarshal(resp.Body(), &voices) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | return voices, nil 59 | } 60 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/wujunwei928/edge-tts-go 2 | 3 | go 1.21.9 4 | 5 | require ( 6 | github.com/go-resty/resty/v2 v2.12.0 // indirect 7 | github.com/google/uuid v1.6.0 // indirect 8 | github.com/gorilla/websocket v1.5.1 // indirect 9 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 10 | github.com/spf13/cobra v1.8.0 // indirect 11 | github.com/spf13/pflag v1.0.5 // indirect 12 | golang.org/x/net v0.22.0 // indirect 13 | ) 14 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 2 | github.com/go-resty/resty/v2 v2.12.0 h1:rsVL8P90LFvkUYq/V5BTVe203WfRIU4gvcf+yfzJzGA= 3 | github.com/go-resty/resty/v2 v2.12.0/go.mod h1:o0yGPrkS3lOe1+eFajk6kBW8ScXzwU3hD69/gt2yB/0= 4 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 5 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 6 | github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= 7 | github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= 8 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 9 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 10 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 11 | github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= 12 | github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= 13 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 14 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 15 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 16 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 17 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 18 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 19 | golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= 20 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 21 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 22 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 23 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 24 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 25 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 26 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 27 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 28 | golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= 29 | golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= 30 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 31 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 32 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 33 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 34 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 35 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 36 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 37 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 38 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 39 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 40 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 41 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 42 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 43 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 44 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 45 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 46 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 47 | golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= 48 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 49 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 50 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 51 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 52 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 53 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 54 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 55 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 56 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 57 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 58 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 59 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 60 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 61 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 62 | -------------------------------------------------------------------------------- /internal/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "github.com/spf13/cobra" 7 | "github.com/wujunwei928/edge-tts-go/edge_tts" 8 | "io" 9 | "os" 10 | ) 11 | 12 | var ( 13 | listVoices bool 14 | text string 15 | file string 16 | voice string 17 | rate string 18 | volume string 19 | pitch string 20 | wordsInCue float64 21 | writeMedia string 22 | proxyURL string // 是否使用代理 23 | ) 24 | 25 | // rootCmd represents the base command when called without any subcommands 26 | var rootCmd = &cobra.Command{ 27 | Use: "edge-tts-go", 28 | Short: "调用Edge TTS服务,文本生成语音", 29 | Long: `调用Edge TTS服务,文本生成语音`, 30 | Version: edge_tts.PackageVersion, // 指定版本号: 会有 -v 和 --version 选项, 用于打印版本号 31 | // Uncomment the following line if your bare application 32 | // has an action associated with it: 33 | RunE: func(cmd *cobra.Command, args []string) error { 34 | // 列出可用的语音 35 | if listVoices { 36 | ListVoices() 37 | return nil 38 | } 39 | 40 | // 文本转语音 41 | 42 | if len(text) <= 0 && len(file) <= 0 { 43 | return errors.New("--text and --file can't be empty at the same time") 44 | } 45 | 46 | inputText := text 47 | if len(file) > 0 { 48 | var ( 49 | fileContent []byte 50 | readFileErr error 51 | ) 52 | 53 | switch file { 54 | case "/dev/stdin": 55 | fileContent, readFileErr = io.ReadAll(os.Stdin) 56 | default: 57 | fileContent, readFileErr = os.ReadFile(file) 58 | } 59 | 60 | if readFileErr != nil { 61 | return readFileErr 62 | } 63 | 64 | inputText = string(fileContent) 65 | } 66 | 67 | connOptions := []edge_tts.CommunicateOption{ 68 | edge_tts.SetVoice(voice), 69 | edge_tts.SetRate(rate), 70 | edge_tts.SetVolume(volume), 71 | edge_tts.SetPitch(pitch), 72 | edge_tts.SetReceiveTimeout(20), 73 | } 74 | if len(proxyURL) > 0 { 75 | connOptions = append(connOptions, edge_tts.SetProxy(proxyURL)) 76 | } 77 | 78 | conn, err := edge_tts.NewCommunicate( 79 | inputText, 80 | connOptions..., 81 | ) 82 | if err != nil { 83 | return err 84 | } 85 | audioData, err := conn.Stream() 86 | if err != nil { 87 | return err 88 | } 89 | 90 | if len(writeMedia) > 0 { 91 | writeMediaErr := os.WriteFile(writeMedia, audioData, 0644) 92 | if writeMediaErr != nil { 93 | return writeMediaErr 94 | } 95 | return nil 96 | } 97 | 98 | // write mp3 file's binary data to stdout 99 | _, err = io.Copy(os.Stdout, bytes.NewReader(audioData)) 100 | if err != nil { 101 | return err 102 | } 103 | 104 | return nil 105 | }, 106 | } 107 | 108 | // Execute adds all child commands to the root command and sets flags appropriately. 109 | // This is called by main.main(). It only needs to happen once to the rootCmd. 110 | func Execute() { 111 | err := rootCmd.Execute() 112 | if err != nil { 113 | os.Exit(1) 114 | } 115 | } 116 | 117 | func init() { 118 | // bind flags 119 | rootCmd.Flags().StringVarP(&text, "text", "t", "", "what TTS will say") 120 | rootCmd.Flags().StringVarP(&file, "file", "f", "", "same as --text but read from file") 121 | rootCmd.Flags().StringVarP(&voice, "voice", "v", "en-US-AriaNeural", "voice for TTS") 122 | rootCmd.Flags().StringVar(&rate, "rate", "+0%", "set TTS rate") 123 | rootCmd.Flags().StringVar(&volume, "volume", "+0%", "set TTS volume") 124 | rootCmd.Flags().StringVar(&pitch, "pitch", "+0Hz", "set TTS pitch") 125 | rootCmd.Flags().Float64Var(&wordsInCue, "words-in-cue", 10, "number of words in a subtitle cue") 126 | rootCmd.Flags().StringVar(&writeMedia, "write-media", "", "send media output to file instead of stdout") 127 | rootCmd.Flags().StringVar(&proxyURL, "proxy", "", "use a proxy for TTS and voice list") 128 | rootCmd.Flags().BoolVar(&listVoices, "list-voices", false, "lists available voices and exits") 129 | } 130 | -------------------------------------------------------------------------------- /internal/cmd/utils.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/wujunwei928/edge-tts-go/edge_tts" 6 | "log" 7 | "strings" 8 | ) 9 | 10 | func ListVoices() { 11 | voiceList, err := edge_tts.ListVoices(proxyURL) 12 | if err != nil { 13 | log.Fatal(err) 14 | } 15 | 16 | for _, voiceItem := range voiceList { 17 | fmt.Println("Name:", voiceItem.Name) 18 | fmt.Println("ShortName:", voiceItem.ShortName) 19 | fmt.Println("Gender:", voiceItem.Gender) 20 | fmt.Println("Locale:", voiceItem.Locale) 21 | fmt.Println("ContentCategories:", strings.Join(voiceItem.VoiceTag.ContentCategories, ",")) 22 | fmt.Println("VoicePersonalities:", strings.Join(voiceItem.VoiceTag.VoicePersonalities, ",")) 23 | fmt.Println() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/wujunwei928/edge-tts-go/internal/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | --------------------------------------------------------------------------------