├── .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 | ![MIT](https://img.shields.io/badge/license-MIT-green) 2 | [![CI](https://github.com/jing332/tts-server-go/actions/workflows/main.yml/badge.svg)](https://github.com/jing332/tts-server-go/actions/workflows/main.yml) 3 | [![Test](https://github.com/jing332/tts-server-go/actions/workflows/test.yml/badge.svg)](https://github.com/jing332/tts-server-go/actions/workflows/test.yml) 4 | ![GitHub release (latest by date)](https://img.shields.io/github/downloads/jing332/tts-server-go/latest/total) 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 |
33 |
34 |
35 | 42 |
43 |
44 |
45 |
46 |
47 | 48 | 49 |
在阅读APP中显示的名称。
50 |
51 |
52 |
53 |
54 | 55 | 56 |
选择所用语言
57 |
58 |
59 |
60 |
61 | 62 | 64 |
声音列表加载可能有点慢,请稍等一下!
65 |
66 |
67 | 75 |
76 |
77 | 78 | 81 |
指定讲话风格。 说话风格特定于语音。
82 |
83 |
84 | 85 | 87 |
指定说话风格的强度。 接受的值:0.01 到 2(含边界值)。 默认值为 1,表示预定义的风格强度。 最小单位为 0.01,表示略倾向于目标风格。 值为 88 | 2 89 | 表示是默认风格强度的两倍。 90 |
91 |
92 |
93 | 94 | 97 |
指定角色扮演。如云希的旁白/小男孩。
98 |
99 |
100 |
101 |
102 | 103 | 131 |
132 |

可以调整音频的格式和质量。质量越高,生成的文件越大,对网速和流量的需求越高。请根据自己的情况选择。 133 |
如果出现 “Unsupported output format: XXX” 错误,表示不支持当前格式。

134 |

注:webm-24khz-16bit-24kbps-mono-opus 实际所耗流量是其音频大小的3倍有余,请谨慎使用。

135 |
136 |
137 | 138 |
139 |
140 | 141 | 142 |
如果没有设置 -token 命令行参数请为空。
143 |
144 |
145 | 146 |
147 |
148 | 149 | 151 |
152 |
153 |
154 |
155 |
156 | 157 |
158 |
159 |
160 |
161 | 164 |
165 | 166 | 173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 | 181 | 182 | 206 | 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 |
36 |
37 | 44 |
45 |
46 |
47 |
48 |
49 | 50 | 51 |
在阅读APP中显示的名称。
52 |
53 |
54 |
55 |
56 | 57 | 59 |
选择所用语言/方言
60 |
61 |
62 |
63 |
64 | 65 | 67 |
列表加载可能有点慢,请稍等一下!
68 |
69 |
70 | 78 |
79 |
80 | 81 | 84 |
指定讲话风格。 说话风格特定于语音。
85 |
86 |
87 | 88 | 90 |
指定说话风格的强度。 接受的值:0.01 到 2(含边界值)。 默认值为 1,表示预定义的风格强度。 最小单位为 0.01,表示略倾向于目标风格。 值为 91 | 2 92 | 表示是默认风格强度的两倍。 93 |
94 |
95 |
96 | 97 | 100 |
指定角色扮演。如云希的旁白/小男孩。
101 |
102 |
103 |
104 |
105 | 106 | 121 |
122 |

可以调整音频的格式和质量。质量越高,生成的文件越大,对网速和流量的需求越高。请根据自己的情况选择。

123 |

如果出现 “Unsupported output format: XXX” 错误,表示不支持当前格式。

124 |
125 |
126 | 127 |
128 |
129 | 130 | 131 |
如果没有设置 -token 命令行参数请为空。
132 |
133 |
134 | 135 |
136 |
137 | 138 | 140 |
141 |
142 |
143 |
144 |
145 | 146 |
147 |
148 |
149 |
150 | 153 |
154 | 155 | 162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 | 170 | 171 | 195 | 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 | 36 |
37 |
38 |
39 | 40 | 41 |
在阅读APP中显示的名称。
42 |
43 |
44 |
45 |
46 | 47 | 49 |
选择所用语言
50 |
51 |
52 |
53 |
54 | 55 | 57 |
声音列表加载可能有点慢,请耐心等待!
58 |
59 |
60 |
61 |
62 | 63 | 68 |
69 |

Edge大声朗读接口目前仅支持这些

70 |
71 |
72 | 73 |
74 |
75 | 76 | 77 |
如果没有设置 -token 命令行参数请为空。
78 |
79 |
80 | 81 |
82 |
83 | 84 | 86 |
87 |
88 |
89 |
90 |
91 | 92 |
93 |
94 |
95 |
96 | 99 |
100 | 101 | 108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 | 116 | 140 | 141 | 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 | --------------------------------------------------------------------------------