├── .gitattributes
├── .github
└── workflows
│ ├── main.yml
│ └── test.yml
├── .gitignore
├── CHANGELOG.md
├── README.md
├── cmd
└── cli
│ └── main.go
├── go.mod
├── go.sum
├── server
├── api.go
├── api_test.go
├── logic.go
└── public
│ ├── azure.html
│ ├── creation.html
│ ├── index.html
│ └── static.js
├── tools.go
├── tools_test.go
└── tts
├── azure
├── azure.go
└── azure_test.go
├── creation
├── creation.go
└── creation_test.go
├── edge
├── edge.go
├── edge_ip_list.go
└── edge_test.go
├── ssml.go
└── ssml_test.go
/.gitattributes:
--------------------------------------------------------------------------------
1 | *.html linguist-language=Go
2 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | paths:
8 | - 'CHANGELOG.md'
9 |
10 | env:
11 | BINARY_PREFIX: "tts-server-go_"
12 | BINARY_SUFFIX: ""
13 | PR_PROMPT: "::warning:: Build artifact will not be uploaded due to the workflow is trigged by pull request."
14 | LD_FLAGS: "-w -s"
15 |
16 | jobs:
17 | build:
18 | name: Build binary CI
19 | runs-on: ubuntu-latest
20 | strategy:
21 | matrix:
22 | goos: [linux, windows, darwin, android]
23 | goarch: ["386", amd64, arm, arm64]
24 | include:
25 | - goos: linux
26 | goarch: mipsle
27 | - goos: linux
28 | goarch: mips
29 | exclude:
30 | - goos: darwin
31 | goarch: arm
32 | - goos: darwin
33 | goarch: "386"
34 | - goos: windows
35 | goarch: arm64
36 |
37 | fail-fast: true
38 | steps:
39 | - uses: actions/checkout@v2
40 | - name: Setup Go environment
41 | uses: actions/setup-go@v2.1.3
42 | with:
43 | go-version: 1.19.1
44 | - name: Cache downloaded module
45 | uses: actions/cache@v2
46 | with:
47 | path: |
48 | ~/.cache/go-build
49 | ~/go/pkg/mod
50 | key: ${{ runner.os }}-go-${{ matrix.goos }}-${{ matrix.goarch }}-${{ hashFiles('**/go.sum') }}
51 | - name: Build binary file
52 | env:
53 | GOOS: ${{ matrix.goos }}
54 | GOARCH: ${{ matrix.goarch }}
55 | IS_PR: ${{ !!github.head_ref }}
56 | run: |
57 | if [ $GOOS == "windows" ]; then export BINARY_SUFFIX="$BINARY_SUFFIX.exe"; fi
58 | if [ "$GOOS" == "android" ]; then declare -A goarch2cc=( ["arm64"]="aarch64-linux-android32-clang" ["arm"]="armv7a-linux-androideabi32-clang" ["amd64"]="x86_64-linux-android32-clang" ["386"]="i686-linux-android32-clang"); export CC="$ANDROID_NDK_LATEST_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/${goarch2cc[$GOARCH]}"; fi
59 | if $IS_PR ; then echo $PR_PROMPT; fi
60 | export BINARY_NAME="$BINARY_PREFIX${GOOS}_$GOARCH$BINARY_SUFFIX"
61 | export CGO_ENABLED=$( [ "$GOOS" == "android" ] && echo 1 || echo 0 )
62 | cd cmd/cli
63 | go build -o "$GITHUB_WORKSPACE/output/$BINARY_NAME" -trimpath -ldflags "$LD_FLAGS" .
64 | - name: Upload artifact
65 | uses: actions/upload-artifact@v2
66 | if: ${{ !github.head_ref }}
67 | with:
68 | name: ${{ matrix.goos }}_${{ matrix.goarch }}
69 | path: output/
70 | release:
71 | needs: build
72 | runs-on: ubuntu-latest
73 | name: Download artifact and Release
74 | steps:
75 | - uses: actions/checkout@v2
76 |
77 | - uses: actions/download-artifact@v3
78 | with:
79 | path: artifacts
80 |
81 | - name: Copy artifacts to releases
82 | run: |
83 | mkdir releases && cp ./artifacts/*/* ./releases
84 | tree
85 |
86 | - name: Get CHANGELOG(TAG)
87 | run: |
88 | echo "tts_tag=$(sed -n '1p' CHANGELOG.md)" >> $GITHUB_ENV
89 | sed -i '1d' CHANGELOG.md #delete first line
90 |
91 | - uses: softprops/action-gh-release@v0.1.14
92 | with:
93 | name: ${{ env.tts_tag }}
94 | tag_name: ${{ env.tts_tag }}
95 | body_path: ${{ github.workspace }}/CHANGELOG.md
96 | draft: false
97 | prerelease: false
98 | files: ${{ github.workspace }}/releases/*
99 | env:
100 | GITHUB_TOKEN: ${{ secrets.TOKEN }}
101 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | paths-ignore:
8 | - "README.md"
9 | - "CHANGELOG.md"
10 | workflow_dispatch:
11 |
12 | env:
13 | BINARY_PREFIX: "tts-server-go_"
14 | BINARY_SUFFIX: ""
15 | PR_PROMPT: "::warning:: Build artifact will not be uploaded due to the workflow is trigged by pull request."
16 | LD_FLAGS: "-w -s"
17 |
18 | jobs:
19 | build:
20 | name: Build binary CI
21 | runs-on: ubuntu-latest
22 | strategy:
23 | matrix:
24 | goos: [linux, windows, darwin, android]
25 | goarch: ["386", amd64, arm, arm64]
26 | include:
27 | - goos: linux
28 | goarch: mipsle
29 | - goos: linux
30 | goarch: mips
31 | exclude:
32 | - goos: darwin
33 | goarch: arm
34 | - goos: darwin
35 | goarch: "386"
36 | - goos: windows
37 | goarch: arm64
38 |
39 | fail-fast: true
40 | steps:
41 | - uses: actions/checkout@v2
42 | - name: Setup Go environment
43 | uses: actions/setup-go@v2.1.3
44 | with:
45 | go-version: 1.19.1
46 | - name: Cache downloaded module
47 | uses: actions/cache@v2
48 | with:
49 | path: |
50 | ~/.cache/go-build
51 | ~/go/pkg/mod
52 | key: ${{ runner.os }}-go-${{ matrix.goos }}-${{ matrix.goarch }}-${{ hashFiles('**/go.sum') }}
53 | - name: Build binary file
54 | env:
55 | GOOS: ${{ matrix.goos }}
56 | GOARCH: ${{ matrix.goarch }}
57 | IS_PR: ${{ !!github.head_ref }}
58 | run: |
59 | if [ $GOOS == "windows" ]; then export BINARY_SUFFIX="$BINARY_SUFFIX.exe"; fi
60 | if [ "$GOOS" == "android" ]; then declare -A goarch2cc=( ["arm64"]="aarch64-linux-android32-clang" ["arm"]="armv7a-linux-androideabi32-clang" ["amd64"]="x86_64-linux-android32-clang" ["386"]="i686-linux-android32-clang"); export CC="$ANDROID_NDK_LATEST_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/${goarch2cc[$GOARCH]}"; fi
61 | if $IS_PR ; then echo $PR_PROMPT; fi
62 | export BINARY_NAME="$BINARY_PREFIX${GOOS}_$GOARCH$BINARY_SUFFIX"
63 | export CGO_ENABLED=$( [ "$GOOS" == "android" ] && echo 1 || echo 0 )
64 | cd cmd/cli
65 | go build -o "$GITHUB_WORKSPACE/output/$BINARY_NAME" -trimpath -ldflags "$LD_FLAGS" .
66 | - name: Upload artifact
67 | uses: actions/upload-artifact@v2
68 | if: ${{ !github.head_ref }}
69 | with:
70 | name: ${{ matrix.goos }}_${{ matrix.goarch }}
71 | path: output/
72 |
73 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /output/
2 | /.idea/
3 | /*.exe
4 | *.mp3
5 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | tts-server_v0.1.9
2 |
3 | 1. 设置写入超时。
4 | 2. Azure连接大小到上限主动断开,节省等待超时时间。
5 | 3. 修复Azure网页无法获取数据。
6 | 4. Azure和Creation支持 `Jenny Multilingual` (其他语言支持中文的也可)朗读中文。
7 | 5. 本地接口的错误处理优化。
8 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 | [](https://github.com/jing332/tts-server-go/actions/workflows/main.yml)
3 | [](https://github.com/jing332/tts-server-go/actions/workflows/test.yml)
4 | 
5 |
6 | # 安卓系统推荐使用 tts-server-android 安装即用
7 | https://github.com/jing332/tts-server-android
8 |
9 | # 下载
10 | 请从 [Release](https://github.com/jing332/tts-server-go/releases) 下载稳定版。
11 |
12 | 或者从 [Actions](https://github.com/jing332/tts-server-go/actions) 下载最新代码构建程序。
13 |
14 | # 使用方法
15 | 直接运行,默认监听1233端口。
16 |
17 | 然后浏览器 `http://localhost:1233` 打开网页,选好配置导入阅读后即可朗读。
18 |
19 | # 提示
20 |
21 | 接口与[ms-ra-forwarder](https://github.com/wxxxcxx/ms-ra-forwarder) 相同:
22 |
23 | 微软Azure接口(延迟高): `http://localhost:1233/api/azure`
24 |
25 | Edge大声朗读接口: `http://localhost:1233/api/ra`
26 |
--------------------------------------------------------------------------------
/cmd/cli/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "flag"
5 | logformat "github.com/antonfisher/nested-logrus-formatter"
6 | "github.com/jing332/tts-server-go/server"
7 | log "github.com/sirupsen/logrus"
8 | "net/http"
9 | "os"
10 | "os/signal"
11 | )
12 |
13 | var port = flag.Int64("port", 1233, "自定义监听端口")
14 | var token = flag.String("token", "", "使用token验证")
15 | var useDnsEdge = flag.Bool("use-dns-edge", false, "使用DNS解析Edge接口,而不是内置的北京微软云节点。")
16 |
17 | func main() {
18 | log.SetFormatter(&logformat.Formatter{HideKeys: true,
19 | TimestampFormat: "01-02|15:04:05",
20 | })
21 | flag.Parse()
22 | if *token != "" {
23 | log.Info("使用Token: ", token)
24 | }
25 | if *useDnsEdge == true {
26 | log.Infof("使用DNS解析Edge接口")
27 | }
28 |
29 | srv := &server.GracefulServer{Token: *token, UseDnsEdge: *useDnsEdge}
30 | srv.HandleFunc()
31 |
32 | go func() {
33 | sigint := make(chan os.Signal, 1)
34 | signal.Notify(sigint, os.Interrupt)
35 | <-sigint
36 | srv.Close()
37 | }()
38 |
39 | if err := srv.ListenAndServe(*port); err != nil && err != http.ErrServerClosed {
40 | log.Fatalf("HTTP server ListenAndServe: %v", err)
41 | }
42 | log.Infoln("服务已关闭")
43 | }
44 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/jing332/tts-server-go
2 |
3 | go 1.19
4 |
5 | require (
6 | github.com/antonfisher/nested-logrus-formatter v1.3.1
7 | github.com/gorilla/websocket v1.5.0
8 | github.com/sirupsen/logrus v1.9.0
9 | )
10 |
11 | require (
12 | github.com/satori/go.uuid v1.2.0 // indirect
13 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
14 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
15 | )
16 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/antonfisher/nested-logrus-formatter v1.3.1 h1:NFJIr+pzwv5QLHTPyKz9UMEoHck02Q9L0FP13b/xSbQ=
2 | github.com/antonfisher/nested-logrus-formatter v1.3.1/go.mod h1:6WTfyWFkBc9+zyBaKIqRrg/KwMqBbodBjgbHjDz7zjA=
3 | github.com/asters1/tools v0.1.5 h1:fFwRfAsNmcSnesQRdBUvQWaal9PAnqttvewLKgHY1L0=
4 | github.com/asters1/tools v0.1.5/go.mod h1:ZRDA5r2/iosXc0r+5o5BAt+zrTTP6lMltD/6yHBF2Wo=
5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
8 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
9 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
10 | github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
11 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
12 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
13 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
14 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
15 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
16 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
17 | github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
18 | github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
19 | github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
20 | github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
21 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
22 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
23 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
24 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
25 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
26 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
27 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
28 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
29 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
30 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
31 |
--------------------------------------------------------------------------------
/server/api.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "context"
5 | "embed"
6 | _ "embed"
7 | "encoding/json"
8 | "errors"
9 | "fmt"
10 | tts_server_go "github.com/jing332/tts-server-go"
11 | "io"
12 | "io/fs"
13 | "net/http"
14 | "strconv"
15 | "sync"
16 | "time"
17 |
18 | "github.com/gorilla/websocket"
19 | "github.com/jing332/tts-server-go/tts/azure"
20 | "github.com/jing332/tts-server-go/tts/creation"
21 | "github.com/jing332/tts-server-go/tts/edge"
22 | log "github.com/sirupsen/logrus"
23 | )
24 |
25 | type GracefulServer struct {
26 | Token string
27 | UseDnsEdge bool
28 |
29 | Server *http.Server
30 | serveMux *http.ServeMux
31 | shutdownLoad chan struct{}
32 |
33 | edgeLock sync.Mutex
34 | azureLock sync.Mutex
35 | creationLock sync.Mutex
36 | }
37 |
38 | //go:embed public/*
39 | var webFiles embed.FS
40 |
41 | // HandleFunc 注册
42 | func (s *GracefulServer) HandleFunc() {
43 | if s.serveMux == nil {
44 | s.serveMux = &http.ServeMux{}
45 | }
46 |
47 | webFilesFs, _ := fs.Sub(webFiles, "public")
48 | s.serveMux.Handle("/", http.FileServer(http.FS(webFilesFs)))
49 | s.serveMux.Handle("/api/legado", http.TimeoutHandler(http.HandlerFunc(s.legadoAPIHandler), 15*time.Second, "timeout"))
50 |
51 | s.serveMux.Handle("/api/azure", http.TimeoutHandler(http.HandlerFunc(s.azureAPIHandler), 30*time.Second, "timeout"))
52 | s.serveMux.Handle("/api/azure/voices", http.TimeoutHandler(http.HandlerFunc(s.azureVoicesAPIHandler), 30*time.Second, "timeout"))
53 |
54 | s.serveMux.Handle("/api/ra", http.TimeoutHandler(http.HandlerFunc(s.edgeAPIHandler), 30*time.Second, "timeout"))
55 |
56 | s.serveMux.Handle("/api/creation", http.TimeoutHandler(http.HandlerFunc(s.creationAPIHandler), 30*time.Second, "timeout"))
57 | s.serveMux.Handle("/api/creation/voices", http.TimeoutHandler(http.HandlerFunc(s.creationVoicesAPIHandler), 30*time.Second, "timeout"))
58 | }
59 |
60 | // ListenAndServe 监听服务
61 | func (s *GracefulServer) ListenAndServe(port int64) error {
62 | if s.shutdownLoad == nil {
63 | s.shutdownLoad = make(chan struct{})
64 | }
65 |
66 | s.Server = &http.Server{
67 | Addr: ":" + strconv.FormatInt(port, 10),
68 | ReadTimeout: 10 * time.Second,
69 | WriteTimeout: 60 * time.Second,
70 | MaxHeaderBytes: 1 << 20,
71 | Handler: s.serveMux,
72 | }
73 |
74 | log.Infof("服务已启动, 监听地址为: %s:%d", tts_server_go.GetOutboundIPString(), port)
75 | err := s.Server.ListenAndServe()
76 | if err == http.ErrServerClosed { /*说明调用Shutdown关闭*/
77 | err = nil
78 | } else if err != nil {
79 | return err
80 | }
81 |
82 | <-s.shutdownLoad /*等待,直到服务关闭*/
83 |
84 | return nil
85 | }
86 |
87 | // Close 强制关闭,会终止连接
88 | func (s *GracefulServer) Close() {
89 | if ttsEdge != nil {
90 | ttsEdge.CloseConn()
91 | ttsEdge = nil
92 | }
93 | if ttsAzure != nil {
94 | ttsAzure.CloseConn()
95 | ttsAzure = nil
96 | }
97 |
98 | _ = s.Server.Close()
99 | _ = s.Shutdown(time.Second * 5)
100 | }
101 |
102 | // Shutdown 关闭监听服务,需等待响应
103 | func (s *GracefulServer) Shutdown(timeout time.Duration) error {
104 | ctx, cancel := context.WithTimeout(context.Background(), timeout)
105 | defer cancel()
106 | err := s.Server.Shutdown(ctx)
107 | if err != nil {
108 | return fmt.Errorf("shutdown失败: %d", err)
109 | }
110 |
111 | close(s.shutdownLoad)
112 | s.shutdownLoad = nil
113 |
114 | return nil
115 | }
116 |
117 | // 验证Token true表示成功或未设置Token
118 | func (s *GracefulServer) verifyToken(w http.ResponseWriter, r *http.Request) bool {
119 | if s.Token != "" {
120 | token := r.Header.Get("Token")
121 | if s.Token != token {
122 | log.Warnf("无效的Token: %s, 远程地址: %s", token, r.RemoteAddr)
123 | w.WriteHeader(http.StatusUnauthorized)
124 | _, _ = w.Write([]byte("无效的Token"))
125 | return false
126 | }
127 | }
128 | return true
129 | }
130 |
131 | var ttsEdge *edge.TTS
132 |
133 | // Microsoft Edge 大声朗读接口
134 | func (s *GracefulServer) edgeAPIHandler(w http.ResponseWriter, r *http.Request) {
135 | s.edgeLock.Lock()
136 | defer s.edgeLock.Unlock()
137 | defer r.Body.Close()
138 | pass := s.verifyToken(w, r)
139 | if !pass {
140 | return
141 | }
142 |
143 | startTime := time.Now()
144 | body, _ := io.ReadAll(r.Body)
145 | ssml := string(body)
146 | format := r.Header.Get("Format")
147 |
148 | log.Infoln("接收到SSML(Edge):", ssml)
149 | if ttsEdge == nil {
150 | ttsEdge = &edge.TTS{DnsLookupEnabled: s.UseDnsEdge}
151 | }
152 |
153 | var succeed = make(chan []byte)
154 | var failed = make(chan error)
155 | go func() {
156 | for i := 0; i < 3; i++ { /* 循环3次, 成功则return */
157 | data, err := ttsEdge.GetAudio(ssml, format)
158 | if err != nil {
159 | if websocket.IsCloseError(err, websocket.CloseAbnormalClosure) { /* 1006异常断开 */
160 | log.Infoln("异常断开, 自动重连...")
161 | time.Sleep(1000) /* 等待一秒 */
162 | } else { /* 正常性错误,如SSML格式错误 */
163 | failed <- err
164 | return
165 | }
166 | } else { /* 成功 */
167 | succeed <- data
168 | return
169 | }
170 | }
171 | }()
172 |
173 | select { /* 阻塞 等待结果 */
174 | case data := <-succeed: /* 成功接收到音频 */
175 | log.Infof("音频下载完成, 大小:%dKB", len(data)/1024)
176 | err := writeAudioData(w, data, format)
177 | if err != nil {
178 | log.Warnln(err)
179 | }
180 | case reason := <-failed: /* 失败 */
181 | ttsEdge.CloseConn()
182 | ttsEdge = nil
183 | writeErrorData(w, http.StatusInternalServerError, "获取音频失败(Edge): "+reason.Error())
184 | case <-r.Context().Done(): /* 与阅读APP断开连接 超时15s */
185 | log.Warnln("客户端(阅读APP)连接 超时关闭/意外断开")
186 | select { /* 3s内如果成功下载, 就保留与微软服务器的连接 */
187 | case <-succeed:
188 | log.Debugln("断开后3s内成功下载")
189 | case <-time.After(time.Second * 3): /* 抛弃WebSocket连接 */
190 | ttsEdge.CloseConn()
191 | ttsEdge = nil
192 | }
193 | }
194 | log.Infof("耗时:%dms\n", time.Since(startTime).Milliseconds())
195 | }
196 |
197 | type LastAudioCache struct {
198 | ssml string
199 | audioData []byte
200 | }
201 |
202 | var ttsAzure *azure.TTS
203 | var audioCache *LastAudioCache
204 |
205 | // 微软Azure TTS接口
206 | func (s *GracefulServer) azureAPIHandler(w http.ResponseWriter, r *http.Request) {
207 | s.azureLock.Lock()
208 | defer s.azureLock.Unlock()
209 | defer r.Body.Close()
210 | pass := s.verifyToken(w, r)
211 | if !pass {
212 | return
213 | }
214 |
215 | startTime := time.Now()
216 | format := r.Header.Get("Format")
217 | body, _ := io.ReadAll(r.Body)
218 | ssml := string(body)
219 | log.Infoln("接收到SSML(Azure): ", ssml)
220 |
221 | if audioCache != nil {
222 | if audioCache.ssml == ssml {
223 | log.Infoln("与上次超时断开时音频SSML一致, 使用缓存...\n")
224 | err := writeAudioData(w, audioCache.audioData, format)
225 | if err != nil {
226 | log.Warnln(err)
227 | } else {
228 | audioCache = nil
229 | }
230 | return
231 | } else { /* SSML不一致, 抛弃 */
232 | audioCache = nil
233 | }
234 | }
235 |
236 | if ttsAzure == nil {
237 | ttsAzure = &azure.TTS{}
238 | }
239 |
240 | var succeed = make(chan []byte)
241 | var failed = make(chan error)
242 | go func() {
243 | for i := 0; i < 3; i++ { /* 循环3次, 成功则return */
244 | data, err := ttsAzure.GetAudio(ssml, format)
245 | if err != nil {
246 | if websocket.IsCloseError(err, websocket.CloseAbnormalClosure) { /* 1006异常断开 */
247 | log.Infoln("异常断开, 自动重连...")
248 | time.Sleep(1000) /* 等待一秒 */
249 | } else { /* 正常性错误,如SSML格式错误 */
250 | failed <- err
251 | return
252 | }
253 | } else { /* 成功 */
254 | succeed <- data
255 | return
256 | }
257 | }
258 | }()
259 |
260 | select { /* 阻塞 等待结果 */
261 | case data := <-succeed: /* 成功接收到音频 */
262 | log.Infof("音频下载完成, 大小:%dKB", len(data)/1024)
263 | err := writeAudioData(w, data, format)
264 | if err != nil {
265 | log.Warnln(err)
266 | }
267 | case reason := <-failed: /* 失败 */
268 | ttsAzure.CloseConn()
269 | ttsAzure = nil
270 | writeErrorData(w, http.StatusInternalServerError, "获取音频失败(Azure): "+reason.Error())
271 | case <-r.Context().Done(): /* 与阅读APP断开连接 超时15s */
272 | log.Warnln("客户端(阅读APP)连接 超时关闭/意外断开")
273 | select { /* 15s内如果成功下载, 就保留与微软服务器的连接 */
274 | case data := <-succeed:
275 | log.Infoln("断开后15s内成功下载")
276 | audioCache = &LastAudioCache{
277 | ssml: ssml,
278 | audioData: data,
279 | }
280 | case <-time.After(time.Second * 15): /* 抛弃WebSocket连接 */
281 | ttsAzure.CloseConn()
282 | ttsAzure = nil
283 | }
284 | }
285 | log.Infof("耗时: %dms\n", time.Since(startTime).Milliseconds())
286 | }
287 |
288 | var ttsCreation *creation.TTS
289 |
290 | func (s *GracefulServer) creationAPIHandler(w http.ResponseWriter, r *http.Request) {
291 | s.creationLock.Lock()
292 | defer s.creationLock.Unlock()
293 | defer r.Body.Close()
294 | pass := s.verifyToken(w, r)
295 | if !pass {
296 | return
297 | }
298 |
299 | startTime := time.Now()
300 | body, _ := io.ReadAll(r.Body)
301 | text := string(body)
302 | log.Infoln("接收到Json(Creation): ", text)
303 |
304 | var reqData CreationJson
305 | err := json.Unmarshal(body, &reqData)
306 | if err != nil {
307 | writeErrorData(w, http.StatusBadRequest, err.Error())
308 | return
309 | }
310 |
311 | if ttsCreation == nil {
312 | ttsCreation = creation.New()
313 | }
314 |
315 | var succeed = make(chan []byte)
316 | var failed = make(chan error)
317 | go func() {
318 | for i := 0; i < 3; i++ { /* 循环3次, 成功则return */
319 | data, err := ttsCreation.GetAudioUseContext(r.Context(), reqData.Text, reqData.Format, reqData.VoiceProperty())
320 | if err != nil {
321 | if errors.Is(err, context.Canceled) {
322 | return
323 | }
324 | if i == 2 { //三次请求都失败
325 | failed <- err
326 | return
327 | }
328 | log.Warnln(err)
329 | log.Warnf("开始第%d次重试...", i+1)
330 | time.Sleep(time.Second * 2)
331 | } else { /* 成功 */
332 | succeed <- data
333 | return
334 | }
335 | }
336 | }()
337 |
338 | select { /* 阻塞 等待结果 */
339 | case data := <-succeed: /* 成功接收到音频 */
340 | log.Infof("音频下载完成, 大小:%dKB", len(data)/1024)
341 | err := writeAudioData(w, data, reqData.Format)
342 | if err != nil {
343 | log.Warnln(err)
344 | }
345 | case reason := <-failed: /* 失败 */
346 | writeErrorData(w, http.StatusInternalServerError, "获取音频失败(Creation): "+reason.Error())
347 | ttsCreation = nil
348 | case <-r.Context().Done(): /* 与阅读APP断开连接 超时15s */
349 | log.Warnln("客户端(阅读APP)连接 超时关闭/意外断开")
350 | }
351 |
352 | log.Infof("耗时: %dms\n", time.Since(startTime).Milliseconds())
353 | }
354 |
355 | /* 写入音频数据到客户端(阅读APP) */
356 | func writeAudioData(w http.ResponseWriter, data []byte, format string) error {
357 | w.Header().Set("Content-Type", formatContentType(format))
358 | w.Header().Set("Content-Length", strconv.FormatInt(int64(len(data)), 10))
359 | w.Header().Set("Connection", "keep-alive")
360 | w.Header().Set("Keep-Alive", "timeout=5")
361 | _, err := w.Write(data)
362 | return err
363 | }
364 |
365 | /* 写入错误信息到客户端 */
366 | func writeErrorData(w http.ResponseWriter, statusCode int, data string) {
367 | log.Warnln(data)
368 | w.WriteHeader(statusCode)
369 | _, err := w.Write([]byte(data))
370 | if err != nil {
371 | log.Warnln(err)
372 | }
373 | }
374 |
375 | /* 阅读APP网络导入API */
376 | func (s *GracefulServer) legadoAPIHandler(w http.ResponseWriter, r *http.Request) {
377 | params := r.URL.Query()
378 | isCreation := params.Get("isCreation")
379 | apiUrl := params.Get("api")
380 | name := params.Get("name")
381 | voiceName := params.Get("voiceName") /* 发音人 */
382 | voiceId := params.Get("voiceId") /* 发音人ID (Creation接口) */
383 | secondaryLocale := params.Get("secondaryLocale") /* 二级语言 */
384 | styleName := params.Get("styleName") /* 风格 */
385 | styleDegree := params.Get("styleDegree") /* 风格强度(0.1-2.0) */
386 | roleName := params.Get("roleName") /* 角色(身份) */
387 | voiceFormat := params.Get("voiceFormat") /* 音频格式 */
388 | token := params.Get("token")
389 | concurrentRate := params.Get("concurrentRate") /* 并发率(请求间隔) 毫秒为单位 */
390 |
391 | var jsonStr []byte
392 | var err error
393 | if isCreation == "1" {
394 | creationJson := &CreationJson{VoiceName: voiceName, VoiceId: voiceId, SecondaryLocale: secondaryLocale, Style: styleName,
395 | StyleDegree: styleDegree, Role: roleName, Format: voiceFormat}
396 | jsonStr, err = genLegadoCreationJson(apiUrl, name, creationJson, token, concurrentRate)
397 | } else {
398 | jsonStr, err = genLegadoJson(apiUrl, name, voiceName, secondaryLocale, styleName, styleDegree, roleName, voiceFormat, token,
399 | concurrentRate)
400 | }
401 | if err != nil {
402 | writeErrorData(w, http.StatusBadRequest, err.Error())
403 | } else {
404 | _, err := w.Write(jsonStr)
405 | if err != nil {
406 | log.Error("网络导入时写入失败:", err)
407 | }
408 | }
409 | }
410 |
411 | /* 发音人数据 */
412 | func (s *GracefulServer) creationVoicesAPIHandler(w http.ResponseWriter, _ *http.Request) {
413 | token, err := creation.GetToken()
414 | if err != nil {
415 | writeErrorData(w, http.StatusInternalServerError, "获取Token失败: "+err.Error())
416 | return
417 | }
418 | data, err := creation.GetVoices(token)
419 | if err != nil {
420 | writeErrorData(w, http.StatusInternalServerError, "获取Voices失败: "+err.Error())
421 | return
422 | }
423 | w.Header().Set("cache-control", "public, max-age=3600, s-maxage=3600")
424 | _, _ = w.Write(data)
425 | }
426 |
427 | func (s *GracefulServer) azureVoicesAPIHandler(w http.ResponseWriter, _ *http.Request) {
428 | data, err := azure.GetVoices()
429 | if err != nil {
430 | writeErrorData(w, http.StatusInternalServerError, "获取Voices失败: "+err.Error())
431 | return
432 | }
433 |
434 | w.Header().Set("cache-control", "public, max-age=3600, s-maxage=3600")
435 | _, _ = w.Write(data)
436 | }
437 |
--------------------------------------------------------------------------------
/server/api_test.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | log "github.com/sirupsen/logrus"
5 | "net/http"
6 | "testing"
7 | "time"
8 | )
9 |
10 | func TestShutdown(t *testing.T) {
11 | s := &GracefulServer{}
12 | s.HandleFunc()
13 | /*网页访问触发 用来测试关闭服务*/
14 | s.serveMux.HandleFunc("/shutdown", func(writer http.ResponseWriter, request *http.Request) {
15 | go func() {
16 | err := s.Shutdown(time.Second * 10)
17 | if err != nil {
18 | log.Warnln(err)
19 | }
20 |
21 | }()
22 | })
23 |
24 | err := s.ListenAndServe(1233)
25 | if err != nil {
26 | log.Println(err)
27 | return
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/server/logic.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "github.com/jing332/tts-server-go/tts"
7 | log "github.com/sirupsen/logrus"
8 | "strconv"
9 | "strings"
10 | "time"
11 | )
12 |
13 | type LegadoJson struct {
14 | ContentType string `json:"contentType"`
15 | Header string `json:"header"`
16 | ID int64 `json:"id"`
17 | LastUpdateTime int64 `json:"lastUpdateTime"`
18 | Name string `json:"name"`
19 | URL string `json:"url"`
20 | ConcurrentRate string `json:"concurrentRate"`
21 | //EnabledCookieJar bool `json:"enabledCookieJar"`
22 | //LoginCheckJs string `json:"loginCheckJs"`
23 | //LoginUI string `json:"loginUi"`
24 | //LoginURL string `json:"loginUrl"`
25 | }
26 |
27 | type CreationJson struct {
28 | Text string `json:"text"`
29 | VoiceName string `json:"voiceName"`
30 | VoiceId string `json:"voiceId"`
31 | SecondaryLocale string `json:"secondaryLocale"`
32 | Rate string `json:"rate"`
33 | Volume string `json:"volume"`
34 | Style string `json:"style"`
35 | StyleDegree string `json:"styleDegree"`
36 | Role string `json:"role"`
37 | Format string `json:"format"`
38 | }
39 |
40 | func (c *CreationJson) VoiceProperty() *tts.VoiceProperty {
41 | rate, err := strconv.ParseInt(removePcmChar(c.Rate), 10, 8)
42 | if err != nil {
43 | log.Errorf("转换语速失败:%s", c.Rate)
44 | rate = 0
45 | err = nil
46 | }
47 |
48 | volume, err := strconv.ParseInt(removePcmChar(c.Volume), 10, 8)
49 | if err != nil {
50 | log.Errorf("转换音量失败:%s", c.Volume)
51 | volume = 0
52 | err = nil
53 | }
54 | styleDegree, err := strconv.ParseFloat(c.StyleDegree, 10)
55 | if err != nil {
56 | log.Errorf("转换风格强度失败:%s", c.StyleDegree)
57 | volume = 0
58 | err = nil
59 | }
60 |
61 | prosody := &tts.Prosody{Rate: int8(rate), Volume: int8(volume)}
62 | expressAs := &tts.ExpressAs{Style: c.Style, StyleDegree: float32(styleDegree), Role: c.Role}
63 | return &tts.VoiceProperty{VoiceName: c.VoiceName, VoiceId: c.VoiceId, SecondaryLocale: c.SecondaryLocale, Prosody: prosody, ExpressAs: expressAs}
64 | }
65 |
66 | // 移除字符串中 % 符号
67 | func removePcmChar(s string) string {
68 | return strings.ReplaceAll(s, "%", "")
69 | }
70 |
71 | const (
72 | textVar = "{{String(speakText).replace(/&/g, '&').replace(/\"/g, '"').replace(/'/g, ''').replace(//g, '>').replace(/\\/g, '')}}"
73 | textVar2 = `{{String(speakText).replace(/&/g, '&').replace(/\"/g, '"').replace(/'/g, ''').replace(//g, '>').replace(/\\/g, '')}}`
74 | rateVar = "{{(speakSpeed -10) * 2}}"
75 | )
76 |
77 | /* 生成阅读APP朗读朗读引擎Json (Edge, Azure) */
78 | func genLegadoJson(api, name, voiceName, secondaryLocale, styleName, styleDegree, roleName, voiceFormat, token, concurrentRate string) ([]byte, error) {
79 | t := time.Now().UnixNano() / 1e6 //毫秒时间戳
80 | var url string
81 | if styleName == "" { /* Edge大声朗读 */
82 | url = api + ` ,{"method":"POST","body":"` + textVar2 + `"}`
84 | } else { /* Azure TTS */
85 | if secondaryLocale == "" {
86 | url = api + ` ,{"method":"POST","body":"` + textVar2 + ` "}`
89 | } else {
90 | url = api + ` ,{"method":"POST","body":"` + textVar2 + ` "}`
93 | }
94 | }
95 |
96 | head := `{"Content-Type":"text/plain","Format":"` + voiceFormat + `", "Token":"` + token + `"}`
97 | legadoJson := &LegadoJson{Name: name, URL: url, ID: t, LastUpdateTime: t, ContentType: formatContentType(voiceFormat),
98 | Header: head, ConcurrentRate: concurrentRate}
99 |
100 | body, err := json.Marshal(legadoJson)
101 | if err != nil {
102 | return nil, err
103 | }
104 |
105 | return body, nil
106 | }
107 |
108 | /* 生成阅读APP朗读引擎Json (Creation) */
109 | func genLegadoCreationJson(api, name string, creationJson *CreationJson, token, concurrentRate string) ([]byte, error) {
110 | creationJson.Text = textVar
111 | creationJson.Rate = rateVar
112 | creationJson.Volume = "0"
113 | var jsonBuf bytes.Buffer
114 | encoder := json.NewEncoder(&jsonBuf)
115 | encoder.SetEscapeHTML(false)
116 | err := encoder.Encode(creationJson)
117 | if err != nil {
118 | return nil, err
119 | }
120 |
121 | t := time.Now().UnixNano() / 1e6 //毫秒时间戳
122 | url := api + `,{"method":"POST","body":` + string(jsonBuf.Bytes()) + `}`
123 | head := `{"Content-Type":"application/json", "Token":"` + token + `"}`
124 |
125 | legadoJson := &LegadoJson{Name: name, URL: url, ID: t, LastUpdateTime: t, ContentType: formatContentType(creationJson.Format),
126 | Header: head, ConcurrentRate: concurrentRate}
127 | body, err := json.Marshal(legadoJson)
128 | return body, err
129 | }
130 |
131 | /* 根据音频格式返回对应的Content-Type */
132 | func formatContentType(format string) string {
133 | t := strings.Split(format, "-")[0]
134 | switch t {
135 | case "audio":
136 | return "audio/mpeg"
137 | case "webm":
138 | return "audio/webm; codec=opus"
139 | case "ogg":
140 | return "audio/ogg; codecs=opus; rate=16000"
141 | case "riff":
142 | return "audio/x-wav"
143 | case "raw":
144 | if strings.HasSuffix(format, "truesilk") {
145 | return "audio/SILK"
146 | } else {
147 | return "audio/basic"
148 | }
149 | }
150 | return ""
151 | }
152 |
--------------------------------------------------------------------------------
/server/public/azure.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Azure接口
6 |
7 |
8 |
9 |
10 |
11 |
12 |
32 |
180 |
181 |
182 |
184 |
185 |
186 |
189 |
190 |
194 |
195 |
196 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
213 |
218 |
219 |
220 |
221 |
225 |
226 |
227 |
228 | 请在阅读的朗读引擎设置中选择网络导入此链接。
229 |
230 |
238 |
239 |
240 |
241 |
242 |
243 |
244 |
245 |
246 |
493 |
494 |
495 |
--------------------------------------------------------------------------------
/server/public/creation.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Creation接口
6 |
7 |
8 |
9 |
10 |
11 |
12 |
33 |
34 |
35 |
46 |
167 |
168 |
169 |
170 |
171 |
173 |
174 |
175 |
178 |
179 |
183 |
184 |
185 |
192 |
193 |
194 |
195 |
196 |
197 |
198 |
202 |
207 |
208 |
209 |
210 |
214 |
215 |
216 |
217 | 请在阅读的朗读引擎设置中选择网络导入此链接。
218 |
219 |
227 |
228 |
229 |
230 |
231 |
232 |
233 |
234 |
235 |
236 |
237 |
483 |
484 |
485 |
--------------------------------------------------------------------------------
/server/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Edge接口
6 |
7 |
8 |
9 |
10 |
11 |
12 |
32 |
33 |
34 | 手机版请在右上角菜单切换到其他接口
35 |
36 |
113 |
114 |
115 |
116 |
118 |
119 |
120 |
123 |
124 |
128 |
129 |
130 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
148 |
153 |
154 |
155 |
156 |
160 |
161 |
162 |
163 | 请在阅读的朗读引擎设置中选择网络导入此链接。
164 |
165 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
342 |
343 |
344 |
--------------------------------------------------------------------------------
/server/public/static.js:
--------------------------------------------------------------------------------
1 | /* 打开导入链接设置对话框 */
2 | function openSettingsModal() {
3 | let model = new bootstrap.Modal(document.getElementById('setIntervalModel'))
4 | document.getElementById('intervalValue').value = localStorage.getItem('interval') || 5000
5 | model.show()
6 | }
7 |
8 | /* 导入链接页面的二维码按钮点击 */
9 | function onQRCodeClick() {
10 | let qrCodeElement = document.getElementById('legadoUrlQRCode')
11 | let url = document.getElementById('legadoUrl').value
12 | if (!qrCodeElement.innerHTML) {
13 | new QRCode(qrCodeElement, {
14 | text: url
15 | });
16 | }
17 | }
18 |
19 | function getSelectedText(elementName) {
20 | let obj = document.getElementsByName(elementName)[0]
21 | let index = obj.selectedIndex;
22 | return obj.options[index].text
23 | }
24 |
25 | /* 一键导入到阅读APP */
26 | function openAppImport() {
27 | let url = document.getElementById('legadoUrl').value
28 | window.location.href = 'legado://import/httpTTS?src=' + url
29 | }
30 |
31 | /* 复制导入链接 */
32 | function copyLegadoUrl() {
33 | let copyBtn = document.getElementById('copyBtn')
34 | copyBtn.disabled = true
35 | setTimeout(function () {
36 | copyBtn.innerText = "复制"
37 | copyBtn.disabled = false
38 | }, 2000)
39 |
40 | try {
41 | document.getElementById("legadoUrl").select()
42 | let ok = document.execCommand("copy");
43 | if (ok) copyBtn.innerText = "已复制到剪贴板"
44 | else copyBtn.innerText = "复制失败 请手动复制"
45 | } catch (e) {
46 | copyBtn.innerText = "复制失败 请手动复制"
47 | }
48 | }
49 |
50 | /* 单位转换 */
51 | function unitConversion(limit) {
52 | let size;
53 | if (limit < 0.1 * 1024) { //小于0.1KB,则转化成B
54 | size = limit.toFixed(2) + "B"
55 | } else if (limit < 0.1 * 1024 * 1024) { //小于0.1MB,则转化成KB
56 | size = (limit / 1024).toFixed(2) + "KB"
57 | } else if (limit < 0.1 * 1024 * 1024 * 1024) { //小于0.1GB,则转化成MB
58 | size = (limit / (1024 * 1024)).toFixed(2) + "MB"
59 | } else { //其他转化成GB
60 | size = (limit / (1024 * 1024 * 1024)).toFixed(2) + "GB"
61 | }
62 |
63 | let sizeStr = size + ""; //转成字符串
64 | let index = sizeStr.indexOf("."); //获取小数点处的索引
65 | let dou = sizeStr.substr(index + 1, 2) //获取小数点后两位的值
66 | if (dou === "00") { //判断后两位是否为00,如果是则删除00
67 | return sizeStr.substring(0, index) + sizeStr.substr(index + 3, 2)
68 | }
69 | return size;
70 | }
71 |
72 | /* 语言选项的汉化 */
73 | let cnLocalLanguage = {
74 | "af-ZA": "南非荷兰语(南非)",
75 | "am-ET": "阿姆哈拉语(埃塞俄比亚)",
76 | "ar-AE": "阿拉伯语(阿拉伯联合酋长国)",
77 | "ar-BH": "阿拉伯语(巴林)",
78 | "ar-DZ": "阿拉伯语(阿尔及利亚)",
79 | "ar-EG": "阿拉伯语(埃及)",
80 | "ar-IL": "阿拉伯语(以色列)",
81 | "ar-IQ": "阿拉伯语(伊拉克)",
82 | "ar-JO": "阿拉伯语(约旦)",
83 | "ar-KW": "阿拉伯语(科威特)",
84 | "ar-LB": "阿拉伯语(黎巴嫩)",
85 | "ar-LY": "阿拉伯语(利比亚)",
86 | "ar-MA": "阿拉伯语(摩洛哥)",
87 | "ar-OM": "阿拉伯语(阿曼)",
88 | "ar-PS": "阿拉伯语(巴勒斯坦占领区)",
89 | "ar-QA": "阿拉伯语(卡塔尔)",
90 | "ar-SA": "阿拉伯语(沙特阿拉伯)",
91 | "ar-SY": "阿拉伯语(叙利亚)",
92 | "ar-TN": "阿拉伯语(突尼斯)",
93 | "ar-YE": "阿拉伯语(也门)",
94 | "az-AZ": "阿塞拜疆语(阿塞拜疆)",
95 | "bg-BG": "保加利亚语(保加利亚)",
96 | "bn-BD": "孟加拉语(孟加拉国)",
97 | "bn-IN": "孟加拉语(印度)",
98 | "bs-BA": "波斯尼亚语(波斯尼亚)",
99 | "ca-ES": "加泰罗尼亚语(西班牙)",
100 | "cs-CZ": "捷克语(捷克)",
101 | "cy-GB": "威尔士语(英国)",
102 | "da-DK": "丹麦语(丹麦)",
103 | "de-AT": "德语(奥地利)",
104 | "de-CH": "德语(瑞士)",
105 | "de-DE": "德语(德国)",
106 | "el-GR": "希腊语(希腊)",
107 | "en-AU": "英语(澳大利亚)",
108 | "en-CA": "英语(加拿大)",
109 | "en-GB": "英语(英国)",
110 | "en-GH": "英语(加纳)",
111 | "en-HK": "英语(香港特别行政区)",
112 | "en-IE": "英语(爱尔兰)",
113 | "en-IN": "英语(印度)",
114 | "en-KE": "英语(肯尼亚)",
115 | "en-NG": "英语(尼日利亚)",
116 | "en-NZ": "英语(新西兰)",
117 | "en-PH": "英语(菲律宾)",
118 | "en-SG": "英语(新加坡)",
119 | "en-TZ": "英语(坦桑尼亚)",
120 | "en-US": "英语(美国)",
121 | "en-ZA": "英语(南非)",
122 | "es-AR": "西班牙语(阿根廷)",
123 | "es-BO": "西班牙语(玻利维亚)",
124 | "es-CL": "西班牙语(智利)",
125 | "es-CO": "西班牙语(哥伦比亚)",
126 | "es-CR": "西班牙语(哥斯达黎加)",
127 | "es-CU": "西班牙语(古巴)",
128 | "es-DO": "西班牙语(多米尼加共和国)",
129 | "es-EC": "西班牙语(厄瓜多尔)",
130 | "es-ES": "西班牙语 (西班牙)",
131 | "es-GQ": "西班牙语(赤道几内亚)",
132 | "es-GT": "西班牙语(危地马拉)",
133 | "es-HN": "西班牙语(洪都拉斯)",
134 | "es-MX": "西班牙语(墨西哥)",
135 | "es-NI": "西班牙(尼加拉瓜)",
136 | "es-PA": "西班牙语(巴拿马)",
137 | "es-PE": "西班牙语(秘鲁)",
138 | "es-PR": "西班牙语(波多黎各)",
139 | "es-PY": "西班牙语(巴拉圭)",
140 | "es-SV": "西班牙语(萨尔瓦多)",
141 | "es-US": "西班牙语(美国)",
142 | "es-UY": "西班牙语(乌拉圭)",
143 | "es-VE": "西班牙语(委内瑞拉)",
144 | "et-EE": "爱沙尼亚语(爱沙尼亚)",
145 | "eu-ES": "巴斯克语",
146 | "fa-IR": "波斯语(伊朗)",
147 | "fi-FI": "芬兰语(芬兰)",
148 | "fil-PH": "菲律宾语(菲律宾)",
149 | "fr-BE": "法语(比利时)",
150 | "fr-CA": "法语(加拿大)",
151 | "fr-CH": "法语(瑞士)",
152 | "fr-FR": "法语(法国)",
153 | "ga-IE": "爱尔兰语(爱尔兰)",
154 | "gl-ES": "加利西亚语",
155 | "gu-IN": "古吉拉特语(印度)",
156 | "he-IL": "希伯来语(以色列)",
157 | "hi-IN": "印地语(印度)",
158 | "hr-HR": "克罗地亚语(克罗地亚)",
159 | "hu-HU": "匈牙利语(匈牙利)",
160 | "hy-AM": "亚美尼亚语(亚美尼亚)",
161 | "id-ID": "印度尼西亚语(印度尼西亚)",
162 | "is-IS": "冰岛语(冰岛)",
163 | "it-CH": "意大利语(瑞士)",
164 | "it-IT": "意大利语(意大利)",
165 | "ja-JP": "日语(日本)",
166 | "jv-ID": "爪哇语(印度尼西亚)",
167 | "ka-GE": "格鲁吉亚语(格鲁吉亚)",
168 | "kk-KZ": "哈萨克语(哈萨克斯坦)",
169 | "km-KH": "高棉语(柬埔寨)",
170 | "kn-IN": "卡纳达语(印度)",
171 | "ko-KR": "韩语(韩国)",
172 | "lo-LA": "老挝语(老挝)",
173 | "lt-LT": "立陶宛语(立陶宛)",
174 | "lv-LV": "拉脱维亚语(拉脱维亚)",
175 | "mk-MK": "马其顿语(北马其顿共和国)",
176 | "ml-IN": "马拉雅拉姆语(印度)",
177 | "mn-MN": "蒙古语(蒙古)",
178 | "mr-IN": "马拉地语(印度)",
179 | "ms-MY": "马来语(马来西亚)",
180 | "mt-MT": "马耳他语(马耳他)",
181 | "my-MM": "缅甸语(缅甸)",
182 | "nb-NO": "挪威语(博克马尔语,挪威)",
183 | "ne-NP": "尼泊尔语(尼泊尔)",
184 | "nl-BE": "荷兰语(比利时)",
185 | "nl-NL": "荷兰语(荷兰)",
186 | "pl-PL": "波兰语(波兰)",
187 | "ps-AF": "普什图语(阿富汗)",
188 | "pt-BR": "葡萄牙语(巴西)",
189 | "pt-PT": "葡萄牙语(葡萄牙)",
190 | "ro-RO": "罗马尼亚语(罗马尼亚)",
191 | "ru-RU": "俄语(俄罗斯)",
192 | "si-LK": "僧伽罗语(斯里兰卡)",
193 | "sk-SK": "斯洛伐克语(斯洛伐克)",
194 | "sl-SI": "斯洛文尼亚语(斯洛文尼亚)",
195 | "so-SO": "索马里语(索马里)",
196 | "sq-AL": "阿尔巴尼亚语(阿尔巴尼亚)",
197 | "sr-RS": "塞尔维亚语(塞尔维亚)",
198 | "su-ID": "巽他语(印度尼西亚)",
199 | "sv-SE": "瑞典语(瑞典)",
200 | "sw-KE": "斯瓦希里语(肯尼亚)",
201 | "sw-TZ": "斯瓦希里语(坦桑尼亚)",
202 | "ta-IN": "泰米尔语(印度)",
203 | "ta-LK": "泰米尔语(斯里兰卡)",
204 | "ta-MY": "泰米尔语(马来西亚)",
205 | "ta-SG": "泰米尔语(新加坡)",
206 | "te-IN": "泰卢固语(印度)",
207 | "th-TH": "泰语(泰国)",
208 | "tr-TR": "土耳其语(土耳其)",
209 | "uk-UA": "乌克兰语(乌克兰)",
210 | "ur-IN": "乌尔都语(印度)",
211 | "ur-PK": "乌尔都语(巴基斯坦)",
212 | "uz-UZ": "乌兹别克语(乌兹别克斯坦)",
213 | "vi-VN": "越南语(越南)",
214 | "wuu-CN": "吴语(中国)",
215 | "yue-CN": "粤语(中国)",
216 | "zh-CN": "中文(普通话,简体)",
217 | "zh-CN-henan": "中文(中原官话河南,简体)河南口音",
218 | "zh-CN-liaoning": "中文(东北官话,简体)辽宁口音",
219 | "zh-CN-shaanxi": "中文(中原官话陕西,简体)陕西口音",
220 | "zh-CN-shandong": "中文(冀鲁官话,简体)山东口音",
221 | "zh-CN-sichuan": "中文(西南普通话,简体)",
222 | "zh-HK": "中文(粤语,繁体)",
223 | "zh-TW": "中文(台湾普通话)",
224 | "zu-ZA": "祖鲁语(南非)",
225 | }
226 | /* 声音选项的汉化,仅用于Edge接口 */
227 | let cnLocalVoice = {
228 | "af-ZA-AdriNeural": "Adri",
229 | "af-ZA-WillemNeural": "Willem",
230 | "am-ET-AmehaNeural": "አምሀ",
231 | "am-ET-MekdesNeural": "መቅደስ",
232 | "ar-AE-FatimaNeural": "فاطمة",
233 | "ar-AE-HamdanNeural": "حمدان",
234 | "ar-BH-AliNeural": "علي",
235 | "ar-BH-LailaNeural": "ليلى",
236 | "ar-DZ-AminaNeural": "أمينة",
237 | "ar-DZ-IsmaelNeural": "إسماعيل",
238 | "ar-EG-SalmaNeural": "سلمى",
239 | "ar-EG-ShakirNeural": "شاكر",
240 | "ar-IQ-BasselNeural": "باسل",
241 | "ar-IQ-RanaNeural": "رنا",
242 | "ar-JO-SanaNeural": "سناء",
243 | "ar-JO-TaimNeural": "تيم",
244 | "ar-KW-FahedNeural": "فهد",
245 | "ar-KW-NouraNeural": "نورا",
246 | "ar-LB-LaylaNeural": "ليلى",
247 | "ar-LB-RamiNeural": "رامي",
248 | "ar-LY-ImanNeural": "إيمان",
249 | "ar-LY-OmarNeural": "أحمد",
250 | "ar-MA-JamalNeural": "جمال",
251 | "ar-MA-MounaNeural": "منى",
252 | "ar-OM-AbdullahNeural": "عبدالله",
253 | "ar-OM-AyshaNeural": "عائشة",
254 | "ar-QA-AmalNeural": "أمل",
255 | "ar-QA-MoazNeural": "معاذ",
256 | "ar-SA-HamedNeural": "حامد",
257 | "ar-SA-ZariyahNeural": "زارية",
258 | "ar-SY-AmanyNeural": "أماني",
259 | "ar-SY-LaithNeural": "ليث",
260 | "ar-TN-HediNeural": "هادي",
261 | "ar-TN-ReemNeural": "ريم",
262 | "ar-YE-MaryamNeural": "مريم",
263 | "ar-YE-SalehNeural": "صالح",
264 | "az-AZ-BabekNeural": "Babək",
265 | "az-AZ-BanuNeural": "Banu",
266 | "bg-BG-BorislavNeural": "Борислав",
267 | "bg-BG-KalinaNeural": "Калина",
268 | "bn-BD-NabanitaNeural": "নবনীতা",
269 | "bn-BD-PradeepNeural": "প্রদ্বীপ",
270 | "bn-IN-BashkarNeural": "ভাস্কর",
271 | "bn-IN-TanishaaNeural": "তানিশা",
272 | "bs-BA-GoranNeural": "Goran",
273 | "bs-BA-VesnaNeural": "Vesna",
274 | "ca-ES-JoanaNeural": "Joana",
275 | "ca-ES-AlbaNeural": "Alba",
276 | "ca-ES-EnricNeural": "Enric",
277 | "cs-CZ-An:ninNeural": "An:nín",
278 | "cs-CZ-VlastaNeural": "Vlasta",
279 | "cy-GB-AledNeural": "Aled",
280 | "cy-GB-NiaNeural": "Nia",
281 | "da-DK-ChristelNeural": "Christel",
282 | "da-DK-JeppeNeural": "Jeppe",
283 | "de-AT-IngridNeural": "Ingrid",
284 | "de-AT-JonasNeural": "Jonas",
285 | "de-CH-JanNeural": "Jan",
286 | "de-CH-LeniNeural": "Leni",
287 | "de-DE-KatjaNeural": "Katja",
288 | "de-DE-AmalaNeural": "Amala",
289 | "de-DE-BerndNeural": "Bernd",
290 | "de-DE-Chris:phNeural": "Chris:ph",
291 | "de-DE-ConradNeural": "Conrad",
292 | "de-DE-ElkeNeural": "Elke",
293 | "de-DE-GiselaNeural": "Gisela",
294 | "de-DE-KasperNeural": "Kasper",
295 | "de-DE-KillianNeural": "Killian",
296 | "de-DE-KlarissaNeural": "Klarissa",
297 | "de-DE-KlausNeural": "Klaus",
298 | "de-DE-LouisaNeural": "Louisa",
299 | "de-DE-MajaNeural": "Maja",
300 | "de-DE-RalfNeural": "Ralf",
301 | "de-DE-TanjaNeural": "Tanja",
302 | "el-GR-AthinaNeural": "Αθηνά",
303 | "el-GR-Nes:rasNeural": "Νέστορας",
304 | "en-AU-NatashaNeural": "Natasha",
305 | "en-AU-WilliamNeural": "William",
306 | "en-AU-AnnetteNeural": "Annette",
307 | "en-AU-CarlyNeural": "Carly",
308 | "en-AU-DarrenNeural": "Darren",
309 | "en-AU-DuncanNeural": "Duncan",
310 | "en-AU-ElsieNeural": "Elsie",
311 | "en-AU-FreyaNeural": "Freya",
312 | "en-AU-JoanneNeural": "Joanne",
313 | "en-AU-KenNeural": "Ken",
314 | "en-AU-KimNeural": "Kim",
315 | "en-AU-NeilNeural": "Neil",
316 | "en-AU-TimNeural": "Tim",
317 | "en-AU-TinaNeural": "Tina",
318 | "en-CA-ClaraNeural": "Clara",
319 | "en-CA-LiamNeural": "Liam",
320 | "en-GB-LibbyNeural": "Libby",
321 | "en-GB-AbbiNeural": "Abbi",
322 | "en-GB-AlfieNeural": "Alfie",
323 | "en-GB-BellaNeural": "Bella",
324 | "en-GB-ElliotNeural": "Elliot",
325 | "en-GB-EthanNeural": "Ethan",
326 | "en-GB-HollieNeural": "Hollie",
327 | "en-GB-MaisieNeural": "Maisie",
328 | "en-GB-NoahNeural": "Noah",
329 | "en-GB-OliverNeural": "Oliver",
330 | "en-GB-OliviaNeural": "Olivia",
331 | "en-GB-ThomasNeural": "Thomas",
332 | "en-GB-RyanNeural": "Ryan",
333 | "en-GB-SoniaNeural": "Sonia",
334 | "en-GB-MiaNeural": "Mia",
335 | "en-HK-SamNeural": "Sam",
336 | "en-HK-YanNeural": "Yan",
337 | "en-IE-ConnorNeural": "Connor",
338 | "en-IE-EmilyNeural": "Emily",
339 | "en-IN-NeerjaNeural": "Neerja",
340 | "en-IN-PrabhatNeural": "Prabhat",
341 | "en-KE-AsiliaNeural": "Asilia",
342 | "en-KE-ChilembaNeural": "Chilemba",
343 | "en-NG-AbeoNeural": "Abeo",
344 | "en-NG-EzinneNeural": "Ezinne",
345 | "en-NZ-MitchellNeural": "Mitchell",
346 | "en-NZ-MollyNeural": "Molly",
347 | "en-PH-JamesNeural": "James",
348 | "en-PH-RosaNeural": "Rosa",
349 | "en-SG-LunaNeural": "Luna",
350 | "en-SG-WayneNeural": "Wayne",
351 | "en-TZ-ElimuNeural": "Elimu",
352 | "en-TZ-ImaniNeural": "Imani",
353 | "en-US-JennyNeural": "Jenny",
354 | "en-US-JennyMultilingualNeural": "Jenny Multilingual",
355 | "en-US-GuyNeural": "Guy",
356 | "en-US-AmberNeural": "Amber",
357 | "en-US-AnaNeural": "Ana",
358 | "en-US-AriaNeural": "Aria",
359 | "en-US-AshleyNeural": "Ashley",
360 | "en-US-BrandonNeural": "Brandon",
361 | "en-US-Chris:pherNeural": "Chris:pher",
362 | "en-US-CoraNeural": "Cora",
363 | "en-US-ElizabethNeural": "Elizabeth",
364 | "en-US-EricNeural": "Eric",
365 | "en-US-JacobNeural": "Jacob",
366 | "en-US-MichelleNeural": "Michelle",
367 | "en-US-MonicaNeural": "Monica",
368 | "en-US-SaraNeural": "Sara",
369 | "en-US-AIGenerate1Neural": "AIGenerate1",
370 | "en-US-AIGenerate2Neural": "AIGenerate2",
371 | "en-US-DavisNeural": "Davis",
372 | "en-US-JaneNeural": "Jane",
373 | "en-US-JasonNeural": "Jason",
374 | "en-US-NancyNeural": "Nancy",
375 | "en-US-RogerNeural": "Roger",
376 | "en-US-SteffanNeural": "Steffan",
377 | "en-US-:nyNeural": ":ny",
378 | "en-ZA-LeahNeural": "Leah",
379 | "en-ZA-LukeNeural": "Luke",
380 | "es-AR-ElenaNeural": "Elena",
381 | "es-AR-:masNeural": ":mas",
382 | "es-BO-MarceloNeural": "Marcelo",
383 | "es-BO-SofiaNeural": "Sofia",
384 | "es-CL-CatalinaNeural": "Catalina",
385 | "es-CL-LorenzoNeural": "Lorenzo",
386 | "es-CO-GonzaloNeural": "Gonzalo",
387 | "es-CO-SalomeNeural": "Salome",
388 | "es-CR-JuanNeural": "Juan",
389 | "es-CR-MariaNeural": "María",
390 | "es-CU-BelkysNeural": "Belkys",
391 | "es-CU-ManuelNeural": "Manuel",
392 | "es-DO-EmilioNeural": "Emilio",
393 | "es-DO-RamonaNeural": "Ramona",
394 | "es-EC-AndreaNeural": "Andrea",
395 | "es-EC-LuisNeural": "Luis",
396 | "es-ES-ElviraNeural": "Elvira",
397 | "es-ES-AbrilNeural": "Abril",
398 | "es-ES-AlvaroNeural": "Álvaro",
399 | "es-ES-ArnauNeural": "Arnau",
400 | "es-ES-DarioNeural": "Dario",
401 | "es-ES-EliasNeural": "Elias",
402 | "es-ES-EstrellaNeural": "Estrella",
403 | "es-ES-IreneNeural": "Irene",
404 | "es-ES-LaiaNeural": "Laia",
405 | "es-ES-LiaNeural": "Lia",
406 | "es-ES-NilNeural": "Nil",
407 | "es-ES-SaulNeural": "Saul",
408 | "es-ES-TeoNeural": "Teo",
409 | "es-ES-TrianaNeural": "Triana",
410 | "es-ES-VeraNeural": "Vera",
411 | "es-GQ-JavierNeural": "Javier",
412 | "es-GQ-TeresaNeural": "Teresa",
413 | "es-GT-AndresNeural": "Andrés",
414 | "es-GT-MartaNeural": "Marta",
415 | "es-HN-CarlosNeural": "Carlos",
416 | "es-HN-KarlaNeural": "Karla",
417 | "es-MX-DaliaNeural": "Dalia",
418 | "es-MX-BeatrizNeural": "Beatriz",
419 | "es-MX-CandelaNeural": "Candela",
420 | "es-MX-CarlotaNeural": "Carlota",
421 | "es-MX-CecilioNeural": "Cecilio",
422 | "es-MX-GerardoNeural": "Gerardo",
423 | "es-MX-JorgeNeural": "Jorge",
424 | "es-MX-LarissaNeural": "Larissa",
425 | "es-MX-Liber:Neural": "Liber:",
426 | "es-MX-LucianoNeural": "Luciano",
427 | "es-MX-MarinaNeural": "Marina",
428 | "es-MX-NuriaNeural": "Nuria",
429 | "es-MX-PelayoNeural": "Pelayo",
430 | "es-MX-RenataNeural": "Renata",
431 | "es-MX-YagoNeural": "Yago",
432 | "es-NI-FedericoNeural": "Federico",
433 | "es-NI-YolandaNeural": "Yolanda",
434 | "es-PA-MargaritaNeural": "Margarita",
435 | "es-PA-Rober:Neural": "Rober:",
436 | "es-PE-AlexNeural": "Alex",
437 | "es-PE-CamilaNeural": "Camila",
438 | "es-PR-KarinaNeural": "Karina",
439 | "es-PR-Vic:rNeural": "Víc:r",
440 | "es-PY-MarioNeural": "Mario",
441 | "es-PY-TaniaNeural": "Tania",
442 | "es-SV-LorenaNeural": "Lorena",
443 | "es-SV-RodrigoNeural": "Rodrigo",
444 | "es-US-AlonsoNeural": "Alonso",
445 | "es-US-PalomaNeural": "Paloma",
446 | "es-UY-MateoNeural": "Mateo",
447 | "es-UY-ValentinaNeural": "Valentina",
448 | "es-VE-PaolaNeural": "Paola",
449 | "es-VE-SebastianNeural": "Sebastián",
450 | "et-EE-AnuNeural": "Anu",
451 | "et-EE-KertNeural": "Kert",
452 | "eu-ES-AinhoaNeural": "Ainhoa",
453 | "eu-ES-AnderNeural": "Ander",
454 | "fa-IR-DilaraNeural": "دلارا",
455 | "fa-IR-FaridNeural": "فرید",
456 | "fi-FI-SelmaNeural": "Selma",
457 | "fi-FI-HarriNeural": "Harri",
458 | "fi-FI-NooraNeural": "Noora",
459 | "fil-PH-AngeloNeural": "Angelo",
460 | "fil-PH-BlessicaNeural": "Blessica",
461 | "fr-BE-CharlineNeural": "Charline",
462 | "fr-BE-GerardNeural": "Gerard",
463 | "fr-CA-SylvieNeural": "Sylvie",
464 | "fr-CA-An:ineNeural": "An:ine",
465 | "fr-CA-JeanNeural": "Jean",
466 | "fr-CH-ArianeNeural": "Ariane",
467 | "fr-CH-FabriceNeural": "Fabrice",
468 | "fr-FR-AlainNeural": "Alain",
469 | "fr-FR-BrigitteNeural": "Brigitte",
470 | "fr-FR-CelesteNeural": "Celeste",
471 | "fr-FR-ClaudeNeural": "Claude",
472 | "fr-FR-CoralieNeural": "Coralie",
473 | "fr-FR-EloiseNeural": "Eloise",
474 | "fr-FR-JacquelineNeural": "Jacqueline",
475 | "fr-FR-JeromeNeural": "Jerome",
476 | "fr-FR-JosephineNeural": "Josephine",
477 | "fr-FR-MauriceNeural": "Maurice",
478 | "fr-FR-YvesNeural": "Yves",
479 | "fr-FR-YvetteNeural": "Yvette",
480 | "fr-FR-DeniseNeural": "Denise",
481 | "fr-FR-HenriNeural": "Henri",
482 | "ga-IE-ColmNeural": "Colm",
483 | "ga-IE-OrlaNeural": "Orla",
484 | "gl-ES-RoiNeural": "Roi",
485 | "gl-ES-SabelaNeural": "Sabela",
486 | "gu-IN-DhwaniNeural": "ધ્વની",
487 | "gu-IN-NiranjanNeural": "નિરંજન",
488 | "he-IL-AvriNeural": "אברי",
489 | "he-IL-HilaNeural": "הילה",
490 | "hi-IN-MadhurNeural": "मधुर",
491 | "hi-IN-SwaraNeural": "स्वरा",
492 | "hr-HR-GabrijelaNeural": "Gabrijela",
493 | "hr-HR-SreckoNeural": "Srećko",
494 | "hu-HU-NoemiNeural": "Noémi",
495 | "hu-HU-TamasNeural": "Tamás",
496 | "hy-AM-AnahitNeural": "Անահիտ",
497 | "hy-AM-HaykNeural": "Հայկ",
498 | "id-ID-ArdiNeural": "Ardi",
499 | "id-ID-GadisNeural": "Gadis",
500 | "is-IS-GudrunNeural": "Guðrún",
501 | "is-IS-GunnarNeural": "Gunnar",
502 | "it-IT-IsabellaNeural": "Isabella",
503 | "it-IT-ElsaNeural": "Elsa",
504 | "it-IT-BenignoNeural": "Benigno",
505 | "it-IT-CalimeroNeural": "Calimero",
506 | "it-IT-CataldoNeural": "Cataldo",
507 | "it-IT-DiegoNeural": "Diego",
508 | "it-IT-FabiolaNeural": "Fabiola",
509 | "it-IT-FiammaNeural": "Fiamma",
510 | "it-IT-GianniNeural": "Gianni",
511 | "it-IT-ImeldaNeural": "Imelda",
512 | "it-IT-IrmaNeural": "Irma",
513 | "it-IT-LisandroNeural": "Lisandro",
514 | "it-IT-PalmiraNeural": "Palmira",
515 | "it-IT-PierinaNeural": "Pierina",
516 | "it-IT-RinaldoNeural": "Rinaldo",
517 | "ja-JP-NanamiNeural": "七海",
518 | "ja-JP-KeitaNeural": "圭太",
519 | "ja-JP-AoiNeural": "碧衣",
520 | "ja-JP-DaichiNeural": "大智",
521 | "ja-JP-MayuNeural": "真夕",
522 | "ja-JP-NaokiNeural": "直紀",
523 | "ja-JP-ShioriNeural": "志織",
524 | "jv-ID-DimasNeural": "Dimas",
525 | "jv-ID-SitiNeural": "Siti",
526 | "ka-GE-EkaNeural": "ეკა",
527 | "ka-GE-GiorgiNeural": "გიორგი",
528 | "kk-KZ-AigulNeural": "Айгүл",
529 | "kk-KZ-DauletNeural": "Дәулет",
530 | "km-KH-PisethNeural": "ពិសិដ្ឋ",
531 | "km-KH-SreymomNeural": "ស្រីមុំ",
532 | "kn-IN-GaganNeural": "ಗಗನ್",
533 | "kn-IN-SapnaNeural": "ಸಪ್ನಾ",
534 | "ko-KR-SunHiNeural": "선히",
535 | "ko-KR-InJoonNeural": "인준",
536 | "ko-KR-BongJinNeural": "봉진",
537 | "ko-KR-GookMinNeural": "국민",
538 | "ko-KR-JiMinNeural": "지민",
539 | "ko-KR-SeoHyeonNeural": "서현",
540 | "ko-KR-SoonBokNeural": "순복",
541 | "ko-KR-YuJinNeural": "유진",
542 | "lo-LA-ChanthavongNeural": "ຈັນທະວົງ",
543 | "lo-LA-KeomanyNeural": "ແກ້ວມະນີ",
544 | "lt-LT-LeonasNeural": "Leonas",
545 | "lt-LT-OnaNeural": "Ona",
546 | "lv-LV-EveritaNeural": "Everita",
547 | "lv-LV-NilsNeural": "Nils",
548 | "mk-MK-AleksandarNeural": "Александар",
549 | "mk-MK-MarijaNeural": "Марија",
550 | "ml-IN-MidhunNeural": "മിഥുൻ",
551 | "ml-IN-SobhanaNeural": "ശോഭന",
552 | "mn-MN-BataaNeural": "Батаа",
553 | "mn-MN-YesuiNeural": "Есүй",
554 | "mr-IN-AarohiNeural": "आरोही",
555 | "mr-IN-ManoharNeural": "मनोहर",
556 | "ms-MY-OsmanNeural": "Osman",
557 | "ms-MY-YasminNeural": "Yasmin",
558 | "mt-MT-GraceNeural": "Grace",
559 | "mt-MT-JosephNeural": "Joseph",
560 | "my-MM-NilarNeural": "နီလာ",
561 | "my-MM-ThihaNeural": "သီဟ",
562 | "nb-NO-PernilleNeural": "Pernille",
563 | "nb-NO-FinnNeural": "Finn",
564 | "nb-NO-IselinNeural": "Iselin",
565 | "ne-NP-HemkalaNeural": "हेमकला",
566 | "ne-NP-SagarNeural": "सागर",
567 | "nl-BE-ArnaudNeural": "Arnaud",
568 | "nl-BE-DenaNeural": "Dena",
569 | "nl-NL-ColetteNeural": "Colette",
570 | "nl-NL-FennaNeural": "Fenna",
571 | "nl-NL-MaartenNeural": "Maarten",
572 | "pl-PL-AgnieszkaNeural": "Agnieszka",
573 | "pl-PL-MarekNeural": "Marek",
574 | "pl-PL-ZofiaNeural": "Zofia",
575 | "ps-AF-GulNawazNeural": " ګل نواز",
576 | "ps-AF-LatifaNeural": "لطيفه",
577 | "pt-BR-FranciscaNeural": "Francisca",
578 | "pt-BR-An:nioNeural": "Antônio",
579 | "pt-BR-BrendaNeural": "Brenda",
580 | "pt-BR-Dona:Neural": "Dona:",
581 | "pt-BR-ElzaNeural": "Elza",
582 | "pt-BR-FabioNeural": "Fabio",
583 | "pt-BR-GiovannaNeural": "Giovanna",
584 | "pt-BR-Humber:Neural": "Humber:",
585 | "pt-BR-JulioNeural": "Julio",
586 | "pt-BR-LeilaNeural": "Leila",
587 | "pt-BR-LeticiaNeural": "Leticia",
588 | "pt-BR-ManuelaNeural": "Manuela",
589 | "pt-BR-NicolauNeural": "Nicolau",
590 | "pt-BR-ValerioNeural": "Valerio",
591 | "pt-BR-YaraNeural": "Yara",
592 | "pt-PT-DuarteNeural": "Duarte",
593 | "pt-PT-FernandaNeural": "Fernanda",
594 | "pt-PT-RaquelNeural": "Raquel",
595 | "ro-RO-AlinaNeural": "Alina",
596 | "ro-RO-EmilNeural": "Emil",
597 | "ru-RU-SvetlanaNeural": "Светлана",
598 | "ru-RU-DariyaNeural": "Дария",
599 | "ru-RU-DmitryNeural": "Дмитрий",
600 | "si-LK-SameeraNeural": "සමීර",
601 | "si-LK-ThiliniNeural": "තිළිණි",
602 | "sk-SK-LukasNeural": "Lukáš",
603 | "sk-SK-Vik:riaNeural": "Viktória",
604 | "sl-SI-PetraNeural": "Petra",
605 | "sl-SI-RokNeural": "Rok",
606 | "so-SO-MuuseNeural": "Muuse",
607 | "so-SO-UbaxNeural": "Ubax",
608 | "sq-AL-AnilaNeural": "Anila",
609 | "sq-AL-IlirNeural": "Ilir",
610 | "sr-RS-NicholasNeural": "Никола",
611 | "sr-RS-SophieNeural": "Софија",
612 | "su-ID-JajangNeural": "Jajang",
613 | "su-ID-TutiNeural": "Tuti",
614 | "sv-SE-SofieNeural": "Sofie",
615 | "sv-SE-HilleviNeural": "Hillevi",
616 | "sv-SE-MattiasNeural": "Mattias",
617 | "sw-KE-RafikiNeural": "Rafiki",
618 | "sw-KE-ZuriNeural": "Zuri",
619 | "sw-TZ-DaudiNeural": "Daudi",
620 | "sw-TZ-RehemaNeural": "Rehema",
621 | "ta-IN-PallaviNeural": "பல்லவி",
622 | "ta-IN-ValluvarNeural": "வள்ளுவர்",
623 | "ta-LK-KumarNeural": "குமார்",
624 | "ta-LK-SaranyaNeural": "சரண்யா",
625 | "ta-MY-KaniNeural": "கனி",
626 | "ta-MY-SuryaNeural": "சூர்யா",
627 | "ta-SG-AnbuNeural": "அன்பு",
628 | "ta-SG-VenbaNeural": "வெண்பா",
629 | "te-IN-MohanNeural": "మోహన్",
630 | "te-IN-ShrutiNeural": "శ్రుతి",
631 | "th-TH-PremwadeeNeural": "เปรมวดี",
632 | "th-TH-AcharaNeural": "อัจฉรา",
633 | "th-TH-NiwatNeural": "นิวัฒน์",
634 | "tr-TR-AhmetNeural": "Ahmet",
635 | "tr-TR-EmelNeural": "Emel",
636 | "uk-UA-OstapNeural": "Остап",
637 | "uk-UA-PolinaNeural": "Поліна",
638 | "ur-IN-GulNeural": "گل",
639 | "ur-IN-SalmanNeural": "سلمان",
640 | "ur-PK-AsadNeural": "اسد",
641 | "ur-PK-UzmaNeural": "عظمیٰ",
642 | "uz-UZ-MadinaNeural": "Madina",
643 | "uz-UZ-SardorNeural": "Sardor",
644 | "vi-VN-HoaiMyNeural": "Hoài My",
645 | "vi-VN-NamMinhNeural": "Nam Minh",
646 | "wuu-CN-Xiao:ngNeural": "晓彤",
647 | "wuu-CN-YunzheNeural": "云哲",
648 | "yue-CN-XiaoMinNeural": "晓敏",
649 | "yue-CN-YunSongNeural": "云松",
650 | "zh-CN-XiaoxiaoNeural": "晓晓",
651 | "zh-CN-YunyangNeural": "云扬",
652 | "zh-CN-XiaochenNeural": "晓辰",
653 | "zh-CN-XiaohanNeural": "晓涵",
654 | "zh-CN-XiaomoNeural": "晓墨",
655 | "zh-CN-XiaoqiuNeural": "晓秋",
656 | "zh-CN-XiaoruiNeural": "晓睿",
657 | "zh-CN-XiaoshuangNeural": "晓双",
658 | "zh-CN-XiaoxuanNeural": "晓萱",
659 | "zh-CN-XiaoyanNeural": "晓颜",
660 | "zh-CN-XiaoyouNeural": "晓悠",
661 | "zh-CN-YunxiNeural": "云希",
662 | "zh-CN-YunyeNeural": "云野",
663 | "zh-CN-XiaomengNeural": "晓梦",
664 | "zh-CN-XiaoyiNeural": "晓伊",
665 | "zh-CN-XiaozhenNeural": "晓甄",
666 | "zh-CN-YunfengNeural": "云枫",
667 | "zh-CN-YunhaoNeural": "云皓",
668 | "zh-CN-YunjianNeural": "云健",
669 | "zh-CN-YunxiaNeural": "云夏",
670 | "zh-CN-YunzeNeural": "云泽",
671 | "zh-CN-henan-YundengNeural": "云登",
672 | "zh-CN-liaoning-XiaobeiNeural": "晓北",
673 | "zh-CN-shaanxi-XiaoniNeural": "晓妮",
674 | "zh-CN-shandong-YunxiangNeural": "云翔",
675 | "zh-CN-sichuan-YunxiNeural": "云希",
676 | "zh-HK-HiuMaanNeural": "曉曼",
677 | "zh-HK-HiuGaaiNeural": "曉佳",
678 | "zh-HK-WanLungNeural": "雲龍",
679 | "zh-TW-HsiaoChenNeural": "曉臻",
680 | "zh-TW-HsiaoYuNeural": "曉雨",
681 | "zh-TW-YunJheNeural": "雲哲",
682 | "zu-ZA-ThandoNeural": "Thando",
683 | "zu-ZA-ThembaNeural": "Themba",
684 | }
685 | /* 风格和身份选项的汉化 */
686 | let cnLocalStyleAndRole = {
687 | 'general': '普通',
688 | 'assistant': '助手',
689 | 'chat': '闲聊',
690 | 'customerservice': '服侍',
691 | 'newscast': '新闻播报',
692 | 'newscast-casual': '新闻播报(冷淡)',
693 | 'affectionate': '温暖亲切',
694 | 'angry': '生气',
695 | 'calm': '平静',
696 | 'cheerful': '欢快',
697 | 'excited': '激动',
698 | 'friendly': '温和',
699 | 'hopeful': '期待',
700 | 'shouting': '喊叫',
701 | 'terrified': '害怕',
702 | 'unfriendly': '冷漠',
703 | 'whispering': '耳语',
704 | 'empathetic': '同情',
705 | 'newscast-formal': '新闻播报(正式)',
706 | 'disgruntled': '不满',
707 | 'fearful': '担心',
708 | 'gentle': '温合文雅',
709 | 'lyrical': '热情奔放',
710 | 'embarrassed': '犹豫',
711 | 'sad': '悲伤',
712 | 'serious': '严肃',
713 | 'depressed': '忧伤',
714 | 'envious': '嫉妒',
715 | 'poetry-reading': '诗歌朗诵',
716 | 'Default': '默认',
717 | //角色(身份):
718 | 'narration-professional': '讲故事(专业)',
719 | 'narration-casual': '讲故事(冷淡)',
720 | 'narration-relaxed': '讲故事(轻松)',
721 | 'Narration-relaxed': '讲故事(轻松)',
722 | 'Sports_commentary_excited': '体育解说(激动)',
723 | 'Sports_commentary': '体育解说',
724 | 'Advertisement_upbeat': '广告推销(积极)',
725 | 'YoungAdultFemale': '女性青年',
726 | 'YoungAdultMale': '男性青年',
727 | 'OlderAdultFemale': '年长女性',
728 | 'OlderAdultMale': '年长男性',
729 | 'SeniorFemale': '高龄女性',
730 | 'SeniorMale': '高龄男性',
731 | 'Girl': '小女孩',
732 | 'Boy': '小男孩',
733 | 'Narrator': '旁白',
734 | }
--------------------------------------------------------------------------------
/tools.go:
--------------------------------------------------------------------------------
1 | package tts_server_go
2 |
3 | import (
4 | uuid "github.com/satori/go.uuid"
5 | "net"
6 | "regexp"
7 | "time"
8 | )
9 |
10 | func GetUUID() string {
11 | return uuid.NewV4().String()
12 | }
13 |
14 | func GetISOTime() string {
15 | T := time.Now().String()
16 | return T[:23][:10] + "T" + T[:23][11:] + "Z"
17 | }
18 |
19 | // ChunkString 根据长度分割string
20 | func ChunkString(s string, chunkSize int) []string {
21 | if len(s) == 0 {
22 | return nil
23 | }
24 | if chunkSize >= len(s) {
25 | return []string{s}
26 | }
27 | chunks := make([]string, 0, (len(s)-1)/chunkSize+1)
28 | currentLen := 0
29 | currentStart := 0
30 | for i := range s {
31 | if currentLen == chunkSize {
32 | chunks = append(chunks, s[currentStart:i])
33 | currentLen = 0
34 | currentStart = i
35 | }
36 | currentLen++
37 | }
38 | chunks = append(chunks, s[currentStart:])
39 | return chunks
40 | }
41 |
42 | // GetOutboundIP 获取本机首选出站IP
43 | func GetOutboundIP() (net.IP, error) {
44 | conn, err := net.Dial("udp", "8.8.8.8:80")
45 | if err != nil {
46 | return nil, err
47 | }
48 | defer conn.Close()
49 |
50 | localAddr := conn.LocalAddr().(*net.UDPAddr)
51 | return localAddr.IP, nil
52 | }
53 |
54 | // GetOutboundIPString 获取本机首选出站IP,如错误则返回 'localhost'
55 | func GetOutboundIPString() string {
56 | netIp, err := GetOutboundIP()
57 | if err != nil {
58 | return "localhost"
59 | }
60 | return netIp.String()
61 | }
62 |
63 | var charRegexp = regexp.MustCompile(`['"<>&/\\]`)
64 | var entityMap = map[string]string{
65 | `'`: `'`,
66 | `"`: `"`,
67 | `<`: `<`,
68 | `>`: `>`,
69 | `&`: `&`,
70 | `/`: ``,
71 | `\`: ``,
72 | }
73 |
74 | // SpecialCharReplace 过滤掉特殊字符
75 | func SpecialCharReplace(s string) string {
76 | return charRegexp.ReplaceAllStringFunc(s, func(s2 string) string {
77 | return entityMap[s2]
78 | })
79 | }
80 |
--------------------------------------------------------------------------------
/tools_test.go:
--------------------------------------------------------------------------------
1 | package tts_server_go
2 |
3 | import "testing"
4 |
5 | func TestGetIP(t *testing.T) {
6 | netIp, err := GetOutboundIP()
7 | if err != nil {
8 | t.Fatal(err)
9 | }
10 | t.Log(netIp.String())
11 | }
12 |
--------------------------------------------------------------------------------
/tts/azure/azure.go:
--------------------------------------------------------------------------------
1 | package azure
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "github.com/gorilla/websocket"
7 | tsg "github.com/jing332/tts-server-go"
8 | log "github.com/sirupsen/logrus"
9 | "io"
10 | "net/http"
11 | "strings"
12 | "time"
13 | )
14 |
15 | const (
16 | wssUrl = `wss://eastus.api.speech.microsoft.com/cognitiveservices/websocket/v1?TricType=AzureDemo&Authorization=bearer%20undefined&X-ConnectionId=`
17 | voicesUrl = `https://eastus.api.speech.microsoft.com/cognitiveservices/voices/list`
18 | )
19 |
20 | type TTS struct {
21 | DialTimeout time.Duration
22 | WriteTimeout time.Duration
23 |
24 | dialContextCancel context.CancelFunc
25 |
26 | uuid string
27 | conn *websocket.Conn
28 | onReadMessage func(messageType int, p []byte, errMessage error) (finished bool)
29 | }
30 |
31 | func (t *TTS) NewConn() error {
32 | log.Infoln("创建WebSocket连接(Azure)...")
33 | if t.WriteTimeout == 0 {
34 | t.WriteTimeout = time.Second * 2
35 | }
36 | if t.DialTimeout == 0 {
37 | t.DialTimeout = time.Second * 3
38 | }
39 |
40 | dl := websocket.Dialer{
41 | EnableCompression: true,
42 | }
43 |
44 | header := http.Header{}
45 | header.Set("Accept-Encoding", "gzip, deflate, br")
46 | header.Set("Origin", "https://azure.microsoft.com")
47 | header.Set("User-Agent", "Mozilla/5.0 (Linux; Android 12; M2012K11AC Build/N6F26Q; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/81.0.4044.117 Mobile Safari/537.36")
48 |
49 | var ctx context.Context
50 | ctx, t.dialContextCancel = context.WithTimeout(context.Background(), t.DialTimeout)
51 | defer func() {
52 | t.dialContextCancel()
53 | t.dialContextCancel = nil
54 | }()
55 |
56 | var err error
57 | var resp *http.Response
58 | t.conn, resp, err = dl.DialContext(ctx, wssUrl+t.uuid, header)
59 | if err != nil {
60 | if resp == nil {
61 | return err
62 | }
63 | return fmt.Errorf("%w: %s", err, resp.Status)
64 | }
65 |
66 | var size = 0
67 | go func() {
68 | for {
69 | if t.conn == nil {
70 | return
71 | }
72 | messageType, p, err := t.conn.ReadMessage()
73 | size += len(p)
74 | if size >= 2000000 { //大于2MB主动断开
75 | t.onReadMessage(-1, nil, &websocket.CloseError{Code: websocket.CloseAbnormalClosure})
76 | t.conn = nil
77 | return
78 | } else {
79 | closed := t.onReadMessage(messageType, p, err)
80 | if closed {
81 | t.conn = nil
82 | return
83 | }
84 | }
85 | }
86 | }()
87 |
88 | return nil
89 | }
90 |
91 | func (t *TTS) CloseConn() {
92 | if t.conn != nil {
93 | if t.dialContextCancel != nil {
94 | t.dialContextCancel()
95 | }
96 | _ = t.conn.Close()
97 | t.conn = nil
98 | }
99 | }
100 |
101 | func (t *TTS) GetAudio(ssml, format string) (audioData []byte, err error) {
102 | err = t.GetAudioStream(ssml, format, func(bytes []byte) {
103 | audioData = append(audioData, bytes...)
104 | })
105 | return audioData, err
106 | }
107 |
108 | func (t *TTS) GetAudioStream(ssml, format string, read func([]byte)) error {
109 | t.uuid = tsg.GetUUID()
110 | if t.conn == nil {
111 | err := t.NewConn()
112 | if err != nil {
113 | return err
114 | }
115 | }
116 |
117 | running := true
118 | defer func() {
119 | running = false
120 | }()
121 |
122 | var finished = make(chan bool)
123 | var failed = make(chan error)
124 | t.onReadMessage = func(messageType int, p []byte, errMessage error) bool {
125 | if messageType == -1 && p == nil && errMessage != nil { //已经断开链接
126 | if running {
127 | failed <- errMessage
128 | }
129 | return true
130 | }
131 |
132 | if messageType == 2 {
133 | index := strings.Index(string(p), "Path:audio")
134 | data := []byte(string(p)[index+12:])
135 | read(data)
136 | } else if messageType == 1 && string(p)[len(string(p))-14:len(string(p))-6] == "turn.end" {
137 | finished <- true
138 | return false
139 | }
140 | return false
141 | }
142 | err := t.sendConfigMessage(format)
143 | if err != nil {
144 | return err
145 | }
146 | err = t.sendSsmlMessage(ssml)
147 | if err != nil {
148 | return err
149 | }
150 |
151 | select {
152 | case <-finished:
153 | return nil
154 | case errMessage := <-failed:
155 | return errMessage
156 | }
157 | }
158 |
159 | func (t *TTS) sendConfigMessage(format string) error {
160 | timestamp := tsg.GetISOTime()
161 | m1 := "Path: speech.config\r\nX-RequestId: " + t.uuid + "\r\nX-Timestamp: " + timestamp +
162 | "\r\nContent-Type: application/json\r\n\r\n{\"context\":{\"system\":{\"name\":\"SpeechSDK\",\"version\":\"1.19.0\",\"build\":\"JavaScript\",\"lang\":\"JavaScript\",\"os\":{\"platform\":\"Browser/Linux x86_64\",\"name\":\"Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101 Firefox/78.0\",\"version\":\"5.0 (X11)\"}}}}"
163 | m2 := "Path: synthesis.context\r\nX-RequestId: " + t.uuid + "\r\nX-Timestamp: " + timestamp +
164 | "\r\nContent-Type: application/json\r\n\r\n{\"synthesis\":{\"audio\":{\"metadataOptions\":{\"sentenceBoundaryEnabled\":false,\"wordBoundaryEnabled\":false},\"outputFormat\":\"" + format + "\"}}}"
165 | _ = t.conn.SetWriteDeadline(time.Now().Add(t.WriteTimeout))
166 | err := t.conn.WriteMessage(websocket.TextMessage, []byte(m1))
167 | if err != nil {
168 | return fmt.Errorf("发送Config1失败: %s", err)
169 | }
170 | _ = t.conn.SetWriteDeadline(time.Now().Add(t.WriteTimeout))
171 | err = t.conn.WriteMessage(websocket.TextMessage, []byte(m2))
172 | if err != nil {
173 | return fmt.Errorf("发送Config2失败: %s", err)
174 | }
175 |
176 | return nil
177 | }
178 |
179 | func (t *TTS) sendSsmlMessage(ssml string) error {
180 | msg := "Path: ssml\r\nX-RequestId: " + t.uuid + "\r\nX-Timestamp: " + tsg.GetISOTime() + "\r\nContent-Type: application/ssml+xml\r\n\r\n" + ssml
181 | _ = t.conn.SetWriteDeadline(time.Now().Add(t.WriteTimeout))
182 | err := t.conn.WriteMessage(websocket.TextMessage, []byte(msg))
183 | if err != nil {
184 | return fmt.Errorf("发送SSML失败: %s", err)
185 | }
186 | return nil
187 | }
188 |
189 | func GetVoices() ([]byte, error) {
190 | req, err := http.NewRequest(http.MethodGet, voicesUrl, nil)
191 | if err != nil {
192 | return nil, err
193 | }
194 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 Edg/107.0.1418.26")
195 | req.Header.Set("X-Ms-Useragent", "SpeechStudio/2021.05.001")
196 | req.Header.Set("Content-Type", "application/json")
197 | req.Header.Set("Origin", "https://azure.microsoft.com")
198 | req.Header.Set("Referer", "https://azure.microsoft.com")
199 |
200 | resp, err := http.DefaultClient.Do(req)
201 | if err != nil {
202 | return nil, err
203 | }
204 | defer resp.Body.Close()
205 |
206 | if resp.StatusCode != http.StatusOK {
207 | return nil, err
208 | }
209 |
210 | body, err := io.ReadAll(resp.Body)
211 | if err != nil {
212 | return nil, err
213 | }
214 | return body, nil
215 | }
216 |
--------------------------------------------------------------------------------
/tts/azure/azure_test.go:
--------------------------------------------------------------------------------
1 | package azure
2 |
3 | import (
4 | "io/ioutil"
5 | "testing"
6 | )
7 |
8 | func TestAzureApi(t *testing.T) {
9 | ssml := ` 这是微软TTS测试文本。 `
10 | tts := &TTS{}
11 | err := tts.NewConn()
12 | if err != nil {
13 | t.Fatal(err)
14 | }
15 | audioData, err := tts.GetAudio(ssml, "audio-24khz-160kbitrate-mono-mp3")
16 | if err != nil {
17 | return
18 | }
19 | if err != nil {
20 | t.Fatal(err)
21 | }
22 |
23 | ioutil.WriteFile("24khz-160kbps.mp3", audioData, 6666)
24 | }
25 |
26 | func TestGetVoices(t *testing.T) {
27 | data, err := GetVoices()
28 | if err != nil {
29 | t.Fatal(err)
30 | }
31 | t.Log(string(data))
32 | }
33 |
34 | //func TestAzureApiRetry(t *testing.T) {
35 | // ssml := `错误 测试文本 `
36 | // _, err := GetAudioForRetry(ssml, "webm-24khz-16bit-mono-opus", 3)
37 | // if err != nil {
38 | // print(err)
39 | // return
40 | // }
41 | //}
42 |
43 | //func TestCloseConn(t *testing.T) {
44 | // go func() {
45 | // time.Sleep(time.Second * 3)
46 | // CloseConn()
47 | // }()
48 | // ssml := ` 测试文本 `
49 | // for i := 0; i < 3; i++ {
50 | // _, err := GetAudio(ssml, "webm-24khz-16bit-mono-opus")
51 | // if err != nil {
52 | // t.Fatal(err)
53 | // }
54 | // }
55 | //}
56 |
--------------------------------------------------------------------------------
/tts/creation/creation.go:
--------------------------------------------------------------------------------
1 | package creation
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "errors"
7 | "fmt"
8 | tts_server_go "github.com/jing332/tts-server-go"
9 | "github.com/jing332/tts-server-go/tts"
10 | "io"
11 | "net/http"
12 | "strings"
13 | "unicode/utf8"
14 | )
15 |
16 | const (
17 | tokenUrl = "https://southeastasia.customvoice.api.speech.microsoft.com/api/texttospeech/v3.0-beta1/accdemopageentry/auth-token"
18 | voicesUrl = "https://southeastasia.customvoice.api.speech.microsoft.com/api/texttospeech/v3.0-beta1/accdemopage/voices"
19 | speakUrl = "https://southeastasia.customvoice.api.speech.microsoft.com/api/texttospeech/v3.0-beta1/accdemopage/speak"
20 | )
21 |
22 | var (
23 | // TokenErr Token已失效
24 | TokenErr = errors.New("unauthorized")
25 | httpStatusCodeErr = errors.New("http状态码不等于200(OK)")
26 | )
27 |
28 | type TTS struct {
29 | Client *http.Client
30 | token string
31 | }
32 |
33 | func New() *TTS {
34 | return &TTS{Client: &http.Client{}}
35 | }
36 |
37 | // ToSsml 转为完整的SSML
38 | func ToSsml(text string, pro *tts.VoiceProperty) string {
39 | pro.Api = tts.ApiCreation
40 | ssml := `\n\n` +
43 | `` +
44 | strings.ReplaceAll(pro.ElementString(text), `"`, `\"`) + ``
45 |
46 | return ssml
47 | }
48 |
49 | func (t *TTS) GetAudio(text, format string, pro *tts.VoiceProperty) (audio []byte, err error) {
50 | return t.GetAudioUseContext(nil, text, format, pro)
51 | }
52 |
53 | func (t *TTS) GetAudioUseContext(ctx context.Context, text, format string, pro *tts.VoiceProperty) (audio []byte, err error) {
54 | if t.token == "" {
55 | s, err := GetToken()
56 | if err != nil {
57 | return nil, fmt.Errorf("获取token失败:%v", err)
58 | }
59 | t.token = s
60 | }
61 |
62 | /* 接口限制 文本长度不能超300 */
63 | if utf8.RuneCountInString(text) > 295 {
64 | chunks := tts_server_go.ChunkString(text, 290)
65 | for _, v := range chunks {
66 | data, err := t.GetAudioUseContext(ctx, v, format, pro)
67 | if err != nil {
68 | return nil, err
69 | }
70 | audio = append(audio, data...)
71 | }
72 | return audio, nil
73 | }
74 |
75 | ssml := ToSsml(text, pro)
76 | audio, err = t.speakBySsml(ctx, ssml, format)
77 | if err != nil {
78 | if errors.Is(err, TokenErr) { /* Token已失效 */
79 | t.token = ""
80 | audio, err = t.GetAudioUseContext(ctx, text, format, pro)
81 | } else {
82 | return nil, err
83 | }
84 | }
85 |
86 | return audio, nil
87 | }
88 |
89 | func (t *TTS) speakBySsml(ctx context.Context, ssml, format string) ([]byte, error) {
90 | payload := strings.NewReader(`{
91 | "ssml": "` + ssml + `",
92 | "ttsAudioFormat": "` + format + `",
93 | "offsetInPlainText": 0,
94 | "lengthInPlainText":` + "300" +
95 | `,"properties": {
96 | "SpeakTriggerSource": "AccTuningPagePlayButton"
97 | }
98 | }`)
99 | req, err := http.NewRequest(http.MethodPost, speakUrl, payload)
100 | if ctx != nil {
101 | req = req.WithContext(ctx)
102 | }
103 |
104 | if err != nil {
105 | return nil, err
106 | }
107 | req.Header.Add("AccDemoPageAuthToken", t.token)
108 | req.Header.Add("Content-Type", "application/json")
109 | req.Header.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 Edg/107.0.1418.42")
110 | resp, err := t.Client.Do(req)
111 | if err != nil {
112 | return nil, err
113 | }
114 | defer resp.Body.Close()
115 | if resp.StatusCode == http.StatusUnauthorized {
116 | return nil, TokenErr
117 | }
118 |
119 | data, err := io.ReadAll(resp.Body)
120 | if err != nil {
121 | return nil, err
122 | }
123 |
124 | if resp.StatusCode != http.StatusOK { /* 服务器返回错误 大概率是SSML格式问题 和 频率过高 */
125 | return nil, errors.New(string(data))
126 | }
127 |
128 | return data, nil
129 | }
130 |
131 | func GetVoices(token string) ([]byte, error) {
132 | payload := strings.NewReader(`{"queryCondition":{"items":[{"name":"VoiceTypeList","value":"StandardVoice","operatorKind":"Contains"}]}}`)
133 |
134 | req, err := http.NewRequest(http.MethodPost, voicesUrl, payload)
135 |
136 | if err != nil {
137 | return nil, err
138 | }
139 | req.Header.Add("AccDemoPageAuthToken", token)
140 | req.Header.Add("User-Agent", "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36")
141 | req.Header.Add("X-Ms-Useragent", "SpeechStudio/2021.05.001")
142 | req.Header.Add("Content-Type", "application/json")
143 |
144 | resp, err := http.DefaultClient.Do(req)
145 | if err != nil {
146 | return nil, err
147 | }
148 | defer resp.Body.Close()
149 |
150 | if resp.StatusCode != http.StatusOK {
151 | return nil, fmt.Errorf("%v: %v, %v", httpStatusCodeErr, resp.StatusCode, resp.Status)
152 | }
153 |
154 | body, err := io.ReadAll(resp.Body)
155 | if err != nil {
156 | return nil, err
157 | }
158 | return body, nil
159 | }
160 |
161 | func GetToken() (string, error) {
162 | resp, err := http.Get(tokenUrl)
163 | if err != nil {
164 | return "", err
165 | }
166 | defer resp.Body.Close()
167 |
168 | if resp.StatusCode != http.StatusOK {
169 | return "", fmt.Errorf("%v: %v, %v", httpStatusCodeErr, resp.StatusCode, resp.Status)
170 | }
171 |
172 | value := make(map[string]string)
173 | err = json.NewDecoder(resp.Body).Decode(&value)
174 | if err != nil {
175 | return "", err
176 | }
177 | return value["authToken"], nil
178 | }
179 |
--------------------------------------------------------------------------------
/tts/creation/creation_test.go:
--------------------------------------------------------------------------------
1 | package creation
2 |
3 | import (
4 | "context"
5 | "github.com/jing332/tts-server-go/tts"
6 | "net/http"
7 | "testing"
8 | "time"
9 | )
10 |
11 | func TestGetToSsml(t *testing.T) {
12 | pro := &tts.VoiceProperty{Api: tts.ApiCreation, VoiceName: "zh-CN-XiaoxiaoNeural",
13 | VoiceId: "5f55541d-c844-4e04-a7f8-1723ffbea4a9",
14 | Prosody: &tts.Prosody{Rate: 0, Pitch: 0, Volume: 0},
15 | ExpressAs: &tts.ExpressAs{Style: "angry", StyleDegree: 1.5, Role: "body"}}
16 |
17 | t.Log(ToSsml("测试文本", pro))
18 | }
19 |
20 | func TestGetAudioUseContext(t *testing.T) {
21 | pro := &tts.VoiceProperty{Api: tts.ApiCreation, VoiceName: "zh-CN-XiaoxiaoNeural",
22 | VoiceId: "5f55541d-c844-4e04-a7f8-1723ffbea4a9",
23 | Prosody: &tts.Prosody{Rate: 0, Pitch: 0, Volume: 0},
24 | ExpressAs: &tts.ExpressAs{Style: "angry", StyleDegree: 1.5, Role: "body"}}
25 |
26 | text := "我是测试文本"
27 | format := "audio-48khz-96kbitrate-mono-mp3"
28 |
29 | ctx, _ := context.WithCancel(context.Background())
30 | c := &TTS{Client: &http.Client{Timeout: time.Second * 2}}
31 | go func() {
32 | //time.Sleep(500)
33 | //t.Log("canceled")
34 | //cancel()
35 | }()
36 |
37 | for i := 0; i < 1000; i++ {
38 | data, err := c.GetAudioUseContext(ctx, text, format, pro)
39 | if err != nil {
40 | t.Fatal(err)
41 | }
42 | t.Log(len(data))
43 | time.Sleep(5 * time.Second)
44 | }
45 |
46 | }
47 |
48 | //
49 | //func TestGetAudio(t *testing.T) {
50 | // //ssml := `\n\n陕西西安`
51 | // arg := &SpeakArg{
52 | // Text: "我是测试文本我是测试文本我是测试文本我是测试文本我是测试文本",
53 | // VoiceName: "zh-CN-XiaoxiaoNeural",
54 | // VoiceId: "5f55541d-c844-4e04-a7f8-1723ffbea4a9",
55 | // Rate: "-50%",
56 | // Style: "general",
57 | // StyleDegree: "1.0",
58 | // Role: "default",
59 | // Volume: "0%",
60 | // Format: "audio-48khz-96kbitrate-mono-mp3",
61 | // }
62 | // ctx, cancel := context.WithCancel(context.Background())
63 | // c := &TTS{Client: &http.Client{Timeout: time.Second * 2}}
64 | // go func() {
65 | // time.Sleep(8000 * time.Second)
66 | // t.Log("cancel")
67 | // cancel()
68 | // }()
69 | // data, err := c.GetAudioUseContext(ctx, arg)
70 | // if err != nil {
71 | // t.Fatal(err)
72 | // }
73 | // t.Log(len(data))
74 | //}
75 | //
76 | //func TestGetAudioBySsml(t *testing.T) {
77 | // c := &TTS{Client: &http.Client{Timeout: time.Second * 5}}
78 | // ssml := `\n\n我是测试文本我是测试文本我是测试文本我是测试文本我是测试文本`
79 | // audio, err := c.GetAudioUseContextBySsml(nil, ssml, "audio-48khz-96kbitrate-mono-mp3")
80 | // if err != nil {
81 | // t.Fatal(err)
82 | // }
83 | // t.Log(len(audio))
84 | //}
85 |
86 | func TestAuthToken(t *testing.T) {
87 | token, err := GetToken()
88 | if err != nil {
89 | t.Fatal(err)
90 | }
91 | t.Log(token)
92 | }
93 |
94 | func TestVoices(t *testing.T) {
95 | token, err := GetToken()
96 | if err != nil {
97 | t.Fatal(err)
98 | }
99 | t.Log(token)
100 |
101 | b, err := GetVoices(token)
102 | if err != nil {
103 | t.Fatal(err)
104 | }
105 | t.Log(string(b))
106 | }
107 |
--------------------------------------------------------------------------------
/tts/edge/edge.go:
--------------------------------------------------------------------------------
1 | package edge
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "github.com/gorilla/websocket"
7 | tsg "github.com/jing332/tts-server-go"
8 | log "github.com/sirupsen/logrus"
9 | "math/rand"
10 | "net"
11 | "net/http"
12 | "strings"
13 | "time"
14 | )
15 |
16 | const (
17 | wssUrl = `wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1?TrustedClientToken=6A5AA1D4EAFF4E9FB37E23D68491D6F4&ConnectionId=`
18 | )
19 |
20 | type TTS struct {
21 | DnsLookupEnabled bool // 使用DNS解析,而不是北京微软云节点。
22 | DialTimeout time.Duration
23 | WriteTimeout time.Duration
24 |
25 | dialContextCancel context.CancelFunc
26 |
27 | uuid string
28 | conn *websocket.Conn
29 | onReadMessage TReadMessage
30 | }
31 |
32 | type TReadMessage func(messageType int, p []byte, errMessage error) (finished bool)
33 |
34 | func (t *TTS) NewConn() error {
35 | log.Infoln("创建WebSocket连接(Edge)...")
36 | if t.WriteTimeout == 0 {
37 | t.WriteTimeout = time.Second * 2
38 | }
39 | if t.DialTimeout == 0 {
40 | t.DialTimeout = time.Second * 3
41 | }
42 |
43 | dl := websocket.Dialer{
44 | EnableCompression: true,
45 | }
46 |
47 | if !t.DnsLookupEnabled {
48 | dialer := &net.Dialer{}
49 | dl.NetDial = func(network, addr string) (net.Conn, error) {
50 | if addr == "speech.platform.bing.com:443" {
51 | rand.Seed(time.Now().Unix())
52 | i := rand.Intn(len(ChinaIpList))
53 | addr = fmt.Sprintf("%s:443", ChinaIpList[i])
54 | }
55 | log.Infoln("connect to IP: " + addr)
56 | return dialer.Dial(network, addr)
57 | }
58 | }
59 |
60 | header := http.Header{}
61 | header.Set("Accept-Encoding", "gzip, deflate, br")
62 | header.Set("Origin", "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold")
63 | header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.66 Safari/537.36 Edg/103.0.1264.44")
64 |
65 | var ctx context.Context
66 | ctx, t.dialContextCancel = context.WithTimeout(context.Background(), t.DialTimeout)
67 | defer func() {
68 | t.dialContextCancel()
69 | t.dialContextCancel = nil
70 | }()
71 |
72 | var err error
73 | var resp *http.Response
74 | t.conn, resp, err = dl.DialContext(ctx, wssUrl+t.uuid, header)
75 | if err != nil {
76 | if resp == nil {
77 | return err
78 | }
79 | return fmt.Errorf("%w: %s", err, resp.Status)
80 | }
81 |
82 | go func() {
83 | for {
84 | if t.conn == nil {
85 | return
86 | }
87 | messageType, p, err := t.conn.ReadMessage()
88 | closed := t.onReadMessage(messageType, p, err)
89 | if closed {
90 | t.conn = nil
91 | return
92 | }
93 | }
94 | }()
95 |
96 | return nil
97 | }
98 |
99 | func (t *TTS) CloseConn() {
100 | if t.conn != nil {
101 | _ = t.conn.Close()
102 | t.conn = nil
103 | }
104 | }
105 |
106 | func (t *TTS) GetAudio(ssml, format string) (audioData []byte, err error) {
107 | t.uuid = tsg.GetUUID()
108 | if t.conn == nil {
109 | err := t.NewConn()
110 | if err != nil {
111 | return nil, err
112 | }
113 | }
114 |
115 | running := true
116 | defer func() { running = false }()
117 | var finished = make(chan bool)
118 | var failed = make(chan error)
119 | t.onReadMessage = func(messageType int, p []byte, errMessage error) bool {
120 | if messageType == -1 && p == nil && errMessage != nil { //已经断开链接
121 | if running {
122 | failed <- errMessage
123 | }
124 | return true
125 | }
126 |
127 | if messageType == websocket.BinaryMessage {
128 | index := strings.Index(string(p), "Path:audio")
129 | data := []byte(string(p)[index+12:])
130 | audioData = append(audioData, data...)
131 | } else if messageType == websocket.TextMessage && string(p)[len(string(p))-14:len(string(p))-6] == "turn.end" {
132 | finished <- true
133 | return false
134 | }
135 | return false
136 | }
137 | err = t.sendConfigMessage(format)
138 | if err != nil {
139 | return nil, err
140 | }
141 | err = t.sendSsmlMessage(ssml)
142 | if err != nil {
143 | return nil, err
144 | }
145 |
146 | select {
147 | case <-finished:
148 | return audioData, err
149 | case errMessage := <-failed:
150 | return nil, errMessage
151 | }
152 | }
153 |
154 | func (t *TTS) sendConfigMessage(format string) error {
155 | cfgMsg := "X-Timestamp:" + tsg.GetISOTime() + "\r\nContent-Type:application/json; charset=utf-8\r\n" + "Path:speech.config\r\n\r\n" +
156 | `{"context":{"synthesis":{"audio":{"metadataoptions":{"sentenceBoundaryEnabled":"false","wordBoundaryEnabled":"false"},"outputFormat":"` + format + `"}}}}`
157 | _ = t.conn.SetWriteDeadline(time.Now().Add(t.WriteTimeout))
158 | err := t.conn.WriteMessage(websocket.TextMessage, []byte(cfgMsg))
159 | if err != nil {
160 | return fmt.Errorf("发送Config失败: %s", err)
161 | }
162 |
163 | return nil
164 | }
165 |
166 | func (t *TTS) sendSsmlMessage(ssml string) error {
167 | msg := "Path: ssml\r\nX-RequestId: " + t.uuid + "\r\nX-Timestamp: " + tsg.GetISOTime() + "\r\nContent-Type: application/ssml+xml\r\n\r\n" + ssml
168 | _ = t.conn.SetWriteDeadline(time.Now().Add(t.WriteTimeout))
169 | err := t.conn.WriteMessage(websocket.TextMessage, []byte(msg))
170 | if err != nil {
171 | return err
172 | }
173 | return nil
174 | }
175 |
--------------------------------------------------------------------------------
/tts/edge/edge_ip_list.go:
--------------------------------------------------------------------------------
1 | package edge
2 |
3 | var ChinaIpList = []string{
4 | // 北京微软云
5 | "202.89.233.100",
6 | "202.89.233.101",
7 | "202.89.233.102",
8 | "202.89.233.103",
9 | "202.89.233.104",
10 |
11 | //"182.61.148.24", 广东百度云
12 | }
13 |
--------------------------------------------------------------------------------
/tts/edge/edge_test.go:
--------------------------------------------------------------------------------
1 | package edge
2 |
3 | import (
4 | log "github.com/sirupsen/logrus"
5 | "os"
6 | "testing"
7 | )
8 |
9 | func TestEdgeApi(t *testing.T) {
10 | ssml := ` 半年后一天,苏浩再次尝试控制身上的血气运动,原本以为会一如既往般毫无动静,没想到意识操控的那部分血气竟然往控制方向移动了一丝。就是这一丝移动,让苏浩欣喜若狂。`
11 | tts := &TTS{}
12 | tts.NewConn()
13 | audioData, err := tts.GetAudio(ssml, "webm-24khz-16bit-mono-opus")
14 | if err != nil {
15 | log.Fatal(err)
16 | return
17 | }
18 |
19 | os.WriteFile("webm-24khz-16bit.mp3", audioData, 0666)
20 | }
21 |
22 | //func TestEdgeApiRetry(t *testing.T) {
23 | // ssml := `错误ssml 半年后一天,苏浩再次尝试控制身上的血气运动,原本以为会一如既往般毫无动静,没想到意识操控的那部分血气竟然往控制方向移动了一丝。就是这一丝移动,让苏浩欣喜若狂。`
24 | // _, err := GetAudioForRetry(ssml, "webm-24khz-16bit-mono-opus", 3)
25 | // if err != nil {
26 | // log.Fatal(err)
27 | // return
28 | // }
29 | //}
30 |
31 | //func TestCloseConn(t *testing.T) {
32 | // go func() {
33 | // time.Sleep(time.Second * 3)
34 | // CloseConn()
35 | // }()
36 | // ssml := ` 半年后一天,苏浩再次尝试控制身上的血气运动,原本以为会一如既往般毫无动静,没想到意识操控的那部分血气竟然往控制方向移动了一丝。就是这一丝移动,让苏浩欣喜若狂。`
37 | // for i := 0; i < 3; i++ {
38 | // _, err := GetAudio(ssml, "webm-24khz-16bit-mono-opus")
39 | // if err != nil {
40 | // t.Fatal(err)
41 | // }
42 | // }
43 | //
44 | //}
45 |
--------------------------------------------------------------------------------
/tts/ssml.go:
--------------------------------------------------------------------------------
1 | package tts
2 |
3 | import (
4 | "strconv"
5 | )
6 |
7 | const (
8 | ApiEdge = 0
9 | ApiAzure = 1
10 | ApiCreation = 2
11 | )
12 |
13 | type VoiceProperty struct {
14 | Api int
15 | VoiceName string
16 | VoiceId string
17 | SecondaryLocale string
18 | *Prosody
19 | *ExpressAs
20 | }
21 |
22 | // ElementString 转为Voice元素字符串
23 | func (v *VoiceProperty) ElementString(text string) string {
24 | var element string
25 | if v.Api == ApiEdge {
26 | element = v.Prosody.ElementString(text)
27 | } else {
28 | element = v.ExpressAs.ElementString(text, v.Prosody)
29 | }
30 | if v.SecondaryLocale == "" {
31 | return `` + element + ``
32 | } else { // 二级语言标签
33 | return `` + element + ``
34 | }
35 | }
36 |
37 | type Prosody struct {
38 | Rate, Volume, Pitch int8
39 | }
40 |
41 | func (p *Prosody) ElementString(text string) string {
42 | return `` + text + ``
46 | }
47 |
48 | type ExpressAs struct {
49 | Style string
50 | StyleDegree float32
51 | Role string
52 | }
53 |
54 | func (e *ExpressAs) ElementString(text string, prosody *Prosody) string {
55 | if e.Style == "" {
56 | e.Style = "general"
57 | }
58 | if e.Role == "" {
59 | e.Role = "default"
60 | }
61 |
62 | return `"` + prosody.ElementString(text) +
66 | ``
67 | }
68 |
--------------------------------------------------------------------------------
/tts/ssml_test.go:
--------------------------------------------------------------------------------
1 | package tts
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | func TestSsml(t *testing.T) {
8 | pro := VoiceProperty{Api: ApiCreation, VoiceName: "zh-CN-XiaoxiaoNeural", SecondaryLocale: "en-US",
9 | VoiceId: "5f55541d-c844-4e04-a7f8-1723ffbea4a9",
10 | Prosody: &Prosody{Rate: 0, Pitch: 0, Volume: 0},
11 | ExpressAs: &ExpressAs{Style: "angry", StyleDegree: 1.5, Role: "body"}}
12 | ssml := pro.ElementString("测试文本")
13 | t.Log(ssml)
14 | }
15 |
16 | func TestProsody(t *testing.T) {
17 | p := Prosody{Rate: 0, Volume: 0, Pitch: 0}
18 | t.Log(p.ElementString("测试文本"))
19 | }
20 |
--------------------------------------------------------------------------------