├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── cmd └── api │ └── main.go ├── configs └── config.yaml ├── go.mod ├── go.sum ├── internal ├── config │ └── config.go ├── http │ ├── handlers │ │ ├── pages.go │ │ ├── tts.go │ │ └── voices.go │ ├── middleware │ │ ├── auth.go │ │ ├── cors.go │ │ └── logger.go │ ├── routes │ │ └── routes.go │ └── server │ │ ├── app.go │ │ └── server.go ├── models │ ├── tts.go │ └── voice.go ├── tts │ ├── microsoft │ │ ├── client.go │ │ └── models.go │ └── service.go └── utils │ └── utils.go ├── script └── build.sh ├── web ├── static │ ├── icons │ │ └── favicon.svg │ └── js │ │ ├── app.js │ │ └── tailwind.js └── templates │ ├── api-doc.html │ └── index.html └── workers ├── package.json ├── src └── index.js └── wrangler.toml /.gitignore: -------------------------------------------------------------------------------- 1 | ### Go template 2 | # If you prefer the allow list template instead of the deny list, see community template: 3 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 4 | # 5 | # Binaries for programs and plugins 6 | *.exe 7 | *.exe~ 8 | *.dll 9 | *.so 10 | *.dylib 11 | 12 | # Test binary, built with `go test -c` 13 | *.test 14 | 15 | # Output of the go coverage tool, specifically when used with LiteIDE 16 | *.out 17 | 18 | # Dependency directories (remove the comment below to include it) 19 | # vendor/ 20 | 21 | # Go workspace file 22 | go.work 23 | go.work.sum 24 | 25 | # env file 26 | .env 27 | 28 | .idea/ 29 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 使用官方 Golang 镜像作为构建环境 2 | FROM golang:1.24-alpine AS builder 3 | ENV CGO_ENABLED=0 4 | ENV GOPROXY=https://goproxy.cn,direct 5 | 6 | # 设置工作目录 7 | WORKDIR /app 8 | 9 | # 将 go.mod 和 go.sum 文件复制到工作目录 10 | COPY go.mod go.sum ./ 11 | 12 | # 下载所有依赖项 13 | RUN go mod download 14 | 15 | # 将源代码复制到工作目录 16 | COPY . . 17 | 18 | # 构建 Go 应用程序 19 | RUN go build -o main ./cmd/api/main.go 20 | 21 | 22 | # 使用 alpine 作为基础镜像 23 | FROM alpine 24 | 25 | # 安装 ffmpeg 和 CA 证书 26 | RUN apk update --no-cache && \ 27 | apk add --no-cache ca-certificates ffmpeg tzdata && \ 28 | rm -rf /var/cache/apk/* 29 | 30 | # 从 builder 阶段复制可执行文件到当前阶段 31 | COPY --from=builder /app/main . 32 | COPY --from=builder /app/web ./web 33 | COPY --from=builder /app/configs ./configs 34 | 35 | # 设置时区 36 | ENV TZ=Asia/Shanghai 37 | 38 | # 暴露端口 39 | EXPOSE 8080 40 | 41 | # 运行应用程序 42 | CMD ["./main"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 WangJinQiang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## TTS 服务 2 | 一个简单易用的文本转语音 (TTS) 服务,基于 Microsoft Azure 语音服务,提供高质量的语音合成能力。 3 | 4 | #### 功能特点 5 | 6 | - 支持多种语言和声音 7 | - 可调节语速和语调 8 | - 支持多种输出音频格式 9 | - 兼容 OpenAI TTS API 10 | - 支持长文本自动分割与合并 11 | - 提供 Web UI 和 RESTful API 12 | 13 | #### 快速开始 14 | 15 | ###### Docker 部署 16 | 17 | ```shell 18 | docker run -d -p 8080:8080 --name=tts zuoban/zb-tts 19 | ``` 20 | 21 | 部署完成后,访问 `http://localhost:8080` 使用 Web 界面,或通过 `http://localhost:8080/api-doc` 查看 API 文档。部署完成后,访问 `http://localhost:8080` 使用 Web 界面,或通过 `http://localhost:8080/api-doc` 查看 API 文档。 22 | 23 | ### Cloudflare Worker 部署 24 | 25 | 1. 创建一个新的 Cloudflare Worker 26 | 2. 复制以下脚本内容到 Worker 27 | [worker.js](https://github.com/zuoban/tts/blob/main/workers/src/index.js) 28 | 3. 添加环境变量 `API_KEY` 29 | - Workers & Pages -> Your Worker -> Settings -> Variables and Secrets -> Add 30 | - Type: `Secret`, Name: `API_KEY`, Value: `YOUR_API_KEY` 31 | 32 | ## API 使用示例 33 | 34 | ### 基础 API 35 | 36 | ```shell 37 | # 基础文本转语音# 基础文本转语音 38 | curl "http://localhost:8080/tts?t=你好,世界&v=zh-CN-XiaoxiaoNeural" 39 | 40 | # 调整语速和语调 41 | curl "http://localhost:8080/tts?t=你好,世界&v=zh-CN-XiaoxiaoNeural&r=20&p=10" 42 | 43 | # 使用情感风格 44 | curl "http://localhost:8080/tts?t=今天天气真好&v=zh-CN-XiaoxiaoNeural&s=cheerful" 45 | ``` 46 | 47 | ### OpenAI 兼容 API 48 | 49 | ```shell 50 | curl -X POST "http://localhost:8080/v1/audio/speech" \ 51 | -H "Content-Type: application/json" \ 52 | -d '{ 53 | "model": "tts-1", 54 | "input": "你好,世界!", 55 | "voice": "zh-CN-XiaoxiaoNeural" 56 | }' 57 | ``` 58 | - model 对应 TTS 服务的 `style` 参数 59 | - voice 对应 TTS 服务的 `voice` 参数 60 | 61 | ## 配置选项 62 | 63 | 您可以通过环境变量或配置文件自定义 TTS 服务: 64 | 65 | ```shell 66 | # 使用自定义端口 67 | docker run -d -p 9000:9000 -e PORT=9000 --name=tts zuoban/zb-tts 68 | 69 | # 使用配置文件 70 | docker run -d -p 8080:8080 -v /path/to/config.yaml:/app/configs/config.yaml --name=tts zuoban/zb-tts 71 | ``` 72 | 73 | ### 配置文件详解 74 | 75 | TTS 服务使用 YAML 格式的配置文件,默认位置为 `/app/configs/config.yaml`。以下是配置文件的主要选项: 76 | 77 | ```yaml 78 | server: 79 | port: 8080 # 服务监听端口 80 | read_timeout: 30 # HTTP 读取超时时间(秒) 81 | write_timeout: 30 # HTTP 写入超时时间(秒) 82 | base_path: "" # API 基础路径前缀,如 "/api" 83 | 84 | tts: 85 | region: "eastasia" # Azure 语音服务区域 86 | default_voice: "zh-CN-XiaoxiaoNeural" # 默认语音 87 | default_rate: "0" # 默认语速,范围 -100 到 100 88 | default_pitch: "0" # 默认语调,范围 -100 到 100 89 | default_format: "audio-24khz-48kbitrate-mono-mp3" # 默认音频格式 90 | max_text_length: 65535 # 最大文本长度 91 | request_timeout: 30 # 请求 Azure 服务的超时时间(秒) 92 | max_concurrent: 10 # 最大并发请求数 93 | segment_threshold: 300 # 文本分段阈值 94 | min_sentence_length: 200 # 最小句子长度 95 | max_sentence_length: 300 # 最大句子长度 96 | api_key: '替换为您的密钥' # (可选, /tts 接口使用) 97 | 98 | # OpenAI 到微软 TTS 中文语音的映射 99 | voice_mapping: 100 | alloy: "zh-CN-XiaoyiNeural" # 中性女声 101 | echo: "zh-CN-YunxiNeural" # 年轻男声 102 | fable: "zh-CN-XiaochenNeural" # 儿童声 103 | onyx: "zh-CN-YunjianNeural" # 成熟男声 104 | nova: "zh-CN-XiaohanNeural" # 活力女声 105 | shimmer: "zh-CN-XiaomoNeural" # 温柔女声 106 | 107 | openai: 108 | api_key: '替换为您的密钥' # OpenAI API 密钥(可选,api 兼容接口使用) 109 | ``` 110 | 111 | 您可以根据自己的需求修改这些配置选项。 其中,`api_key` 为接口认证密钥,若不设置,则不需要认证。 112 | 113 | 以上配置均可通过环境变量进行覆盖,如 `SERVER_PORT`、`OPENAI_API_KEY` 等。 114 | 115 | 使用环境变量时,变量名需转换为大写并使用下划线代替点号。 116 | 117 | 118 | ## 本地构建与运行 119 | 120 | 要从源码构建和运行: 121 | 122 | ```shell 123 | # 克隆仓库 124 | git clone https://github.com/zuoban/tts.git 125 | cd tts 126 | 127 | # 构建 128 | go build -o tts ./cmd/api 129 | 130 | # 运行 131 | ./tts 132 | ``` 133 | 134 | ## 支持的音频格式 135 | 136 | - MP3: `audio-24khz-48kbitrate-mono-mp3`(默认) 137 | - MP3: `audio-24khz-96kbitrate-mono-mp3` 138 | - MP3: `audio-24khz-160kbitrate-mono-mp3` 139 | - WAV: `riff-24khz-16bit-mono-pcm` 140 | - OGG: `ogg-24khz-16bit-mono-opus` 141 | 142 | 更多格式请参考 API 文档。 143 | 144 | ## 许可证 145 | 146 | MIT 147 | -------------------------------------------------------------------------------- /cmd/api/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | 9 | "tts/internal/http/server" 10 | ) 11 | 12 | func main() { 13 | // 解析命令行参数 14 | configPath := flag.String("config", "", "配置文件路径") 15 | flag.Parse() 16 | 17 | // 如果没有指定配置文件,尝试默认位置 18 | if *configPath == "" { 19 | // 尝试多个位置查找配置文件 20 | possiblePaths := []string{ 21 | "./configs/config.yaml", 22 | "../configs/config.yaml", 23 | "/etc/tts/config.yaml", 24 | } 25 | 26 | for _, path := range possiblePaths { 27 | if _, err := os.Stat(path); err == nil { 28 | *configPath = path 29 | break 30 | } 31 | } 32 | 33 | // 如果还是没找到,使用默认位置 34 | if *configPath == "" { 35 | *configPath = "./configs/config.yaml" 36 | } 37 | } 38 | 39 | // 确保配置文件路径是绝对路径 40 | absConfigPath, err := filepath.Abs(*configPath) 41 | if err != nil { 42 | log.Fatalf("无法获取配置文件的绝对路径: %v", err) 43 | } 44 | 45 | // 打印使用的配置文件路径 46 | log.Printf("使用配置文件: %s", absConfigPath) 47 | 48 | // 创建并启动应用 49 | app, err := server.NewApp(absConfigPath) 50 | if err != nil { 51 | log.Fatalf("初始化应用失败: %v", err) 52 | } 53 | 54 | // 启动应用并处理错误 55 | if err := app.Start(); err != nil { 56 | log.Fatalf("应用运行出错: %v", err) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /configs/config.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 8080 3 | read_timeout: 60 4 | write_timeout: 60 5 | base_path: "" 6 | 7 | tts: 8 | region: "eastasia" 9 | default_voice: "zh-CN-XiaoxiaoNeural" 10 | default_rate: "0" 11 | default_pitch: "0" 12 | default_format: "audio-24khz-48kbitrate-mono-mp3" 13 | max_text_length: 65535 14 | request_timeout: 30 15 | max_concurrent: 20 16 | segment_threshold: 300 17 | min_sentence_length: 200 18 | max_sentence_length: 300 19 | api_key: '' 20 | 21 | # OpenAI 到微软 TTS 中文语音的映射 22 | voice_mapping: 23 | alloy: "zh-CN-XiaoyiNeural" # 中性女声 24 | echo: "zh-CN-YunxiNeural" # 年轻男声 25 | fable: "zh-CN-XiaochenNeural" # 儿童声 26 | onyx: "zh-CN-YunjianNeural" # 成熟男声 27 | nova: "zh-CN-XiaohanNeural" # 活力女声 28 | shimmer: "zh-CN-XiaomoNeural" # 温柔女声 29 | openai: 30 | api_key: '' 31 | 32 | ssml: 33 | preserve_tags: 34 | - name: break 35 | pattern: ]*/> 36 | - name: speak 37 | pattern: | 38 | - name: prosody 39 | pattern: ]*>| 40 | - name: emphasis 41 | pattern: ]*>| 42 | - name: voice 43 | pattern: ]*>| 44 | - name: say-as 45 | pattern: ]*>| 46 | - name: phoneme 47 | pattern: ]*>| 48 | - name: audio 49 | pattern: ]*>| 50 | - name: p 51 | pattern:

|

52 | - name: s 53 | pattern: | 54 | - name: sub 55 | pattern: ]*>| 56 | - name: mstts 57 | pattern: ]*>|]*> -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module tts 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.0 6 | 7 | require ( 8 | github.com/gin-gonic/gin v1.10.0 9 | github.com/google/uuid v1.6.0 10 | github.com/sirupsen/logrus v1.9.3 11 | github.com/spf13/viper v1.19.0 12 | ) 13 | 14 | require ( 15 | github.com/bytedance/sonic v1.13.1 // indirect 16 | github.com/bytedance/sonic/loader v0.2.4 // indirect 17 | github.com/cloudwego/base64x v0.1.5 // indirect 18 | github.com/fsnotify/fsnotify v1.7.0 // indirect 19 | github.com/gabriel-vasile/mimetype v1.4.8 // indirect 20 | github.com/gin-contrib/sse v1.0.0 // indirect 21 | github.com/go-playground/locales v0.14.1 // indirect 22 | github.com/go-playground/universal-translator v0.18.1 // indirect 23 | github.com/go-playground/validator/v10 v10.25.0 // indirect 24 | github.com/goccy/go-json v0.10.5 // indirect 25 | github.com/hashicorp/hcl v1.0.0 // indirect 26 | github.com/json-iterator/go v1.1.12 // indirect 27 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect 28 | github.com/leodido/go-urn v1.4.0 // indirect 29 | github.com/magiconair/properties v1.8.7 // indirect 30 | github.com/mattn/go-isatty v0.0.20 // indirect 31 | github.com/mitchellh/mapstructure v1.5.0 // indirect 32 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 33 | github.com/modern-go/reflect2 v1.0.2 // indirect 34 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 35 | github.com/sagikazarmark/locafero v0.4.0 // indirect 36 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 37 | github.com/sourcegraph/conc v0.3.0 // indirect 38 | github.com/spf13/afero v1.11.0 // indirect 39 | github.com/spf13/cast v1.6.0 // indirect 40 | github.com/spf13/pflag v1.0.5 // indirect 41 | github.com/subosito/gotenv v1.6.0 // indirect 42 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 43 | github.com/ugorji/go/codec v1.2.12 // indirect 44 | go.uber.org/atomic v1.9.0 // indirect 45 | go.uber.org/multierr v1.9.0 // indirect 46 | golang.org/x/arch v0.15.0 // indirect 47 | golang.org/x/crypto v0.36.0 // indirect 48 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect 49 | golang.org/x/net v0.37.0 // indirect 50 | golang.org/x/sys v0.31.0 // indirect 51 | golang.org/x/text v0.23.0 // indirect 52 | google.golang.org/protobuf v1.36.5 // indirect 53 | gopkg.in/ini.v1 v1.67.0 // indirect 54 | gopkg.in/yaml.v3 v3.0.1 // indirect 55 | ) 56 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bytedance/sonic v1.13.1 h1:Jyd5CIvdFnkOWuKXr+wm4Nyk2h0yAFsr8ucJgEasO3g= 2 | github.com/bytedance/sonic v1.13.1/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= 3 | github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= 4 | github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= 5 | github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= 6 | github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= 7 | github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= 8 | github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= 9 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 12 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 14 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 15 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 16 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 17 | github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= 18 | github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= 19 | github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E= 20 | github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0= 21 | github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= 22 | github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= 23 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 24 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 25 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 26 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 27 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 28 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 29 | github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8= 30 | github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= 31 | github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 32 | github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 33 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 34 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 35 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 36 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 37 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 38 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 39 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 40 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 41 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 42 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 43 | github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= 44 | github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 45 | github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= 46 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 47 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 48 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 49 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 50 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 51 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 52 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= 53 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 54 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 55 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 56 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 57 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 58 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 59 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 60 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 61 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 62 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 63 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 64 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 65 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 66 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 67 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 68 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 69 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 70 | github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= 71 | github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= 72 | github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= 73 | github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= 74 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 75 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 76 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 77 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 78 | github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= 79 | github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= 80 | github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= 81 | github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 82 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 83 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 84 | github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= 85 | github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= 86 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 87 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 88 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 89 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 90 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 91 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 92 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 93 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 94 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 95 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 96 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 97 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 98 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 99 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 100 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 101 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 102 | github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= 103 | github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 104 | go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= 105 | go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 106 | go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= 107 | go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= 108 | golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw= 109 | golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE= 110 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 111 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 112 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= 113 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= 114 | golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= 115 | golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 116 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 117 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 118 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 119 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 120 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 121 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 122 | google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= 123 | google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 124 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 125 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 126 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 127 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 128 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 129 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 130 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 131 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 132 | nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= 133 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "html" 6 | "regexp" 7 | "strings" 8 | "sync" 9 | 10 | "github.com/spf13/viper" 11 | ) 12 | 13 | // Config 包含应用程序的所有配置 14 | type Config struct { 15 | Server ServerConfig `mapstructure:"server"` 16 | TTS TTSConfig `mapstructure:"tts"` 17 | OpenAI OpenAIConfig `mapstructure:"openai"` 18 | SSML SSMLConfig `mapstructure:"ssml"` 19 | } 20 | 21 | // OpenAIConfig 包含OpenAI API配置 22 | type OpenAIConfig struct { 23 | ApiKey string `mapstructure:"api_key"` 24 | } 25 | 26 | // ServerConfig 包含HTTP服务器配置 27 | type ServerConfig struct { 28 | Port int `mapstructure:"port"` 29 | ReadTimeout int `mapstructure:"read_timeout"` 30 | WriteTimeout int `mapstructure:"write_timeout"` 31 | BasePath string `mapstructure:"base_path"` 32 | } 33 | 34 | // TTSConfig 包含Microsoft TTS API配置 35 | type TTSConfig struct { 36 | ApiKey string `mapstructure:"api_key"` 37 | Region string `mapstructure:"region"` 38 | DefaultVoice string `mapstructure:"default_voice"` 39 | DefaultRate string `mapstructure:"default_rate"` 40 | DefaultPitch string `mapstructure:"default_pitch"` 41 | DefaultFormat string `mapstructure:"default_format"` 42 | MaxTextLength int `mapstructure:"max_text_length"` 43 | RequestTimeout int `mapstructure:"request_timeout"` 44 | MaxConcurrent int `mapstructure:"max_concurrent"` 45 | SegmentThreshold int `mapstructure:"segment_threshold"` 46 | MinSentenceLength int `mapstructure:"min_sentence_length"` 47 | MaxSentenceLength int `mapstructure:"max_sentence_length"` 48 | VoiceMapping map[string]string `mapstructure:"voice_mapping"` 49 | } 50 | 51 | var ( 52 | config Config 53 | once sync.Once 54 | ) 55 | 56 | // Load 从指定路径加载配置文件 57 | func Load(configPath string) (*Config, error) { 58 | var err error 59 | once.Do(func() { 60 | v := viper.New() 61 | 62 | // 配置 Viper 63 | v.SetConfigName("config") 64 | v.SetConfigType("yaml") 65 | v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) 66 | v.AutomaticEnv() // 自动绑定环境变量 67 | 68 | // 从配置文件加载 69 | if configPath != "" { 70 | v.SetConfigFile(configPath) 71 | if err = v.ReadInConfig(); err != nil { 72 | err = fmt.Errorf("加载配置文件失败: %w", err) 73 | return 74 | } 75 | } 76 | 77 | // 将配置绑定到结构体 78 | if err = v.Unmarshal(&config); err != nil { 79 | err = fmt.Errorf("解析配置失败: %w", err) 80 | return 81 | } 82 | }) 83 | 84 | if err != nil { 85 | return nil, err 86 | } 87 | 88 | return &config, nil 89 | } 90 | 91 | // Get 返回已加载的配置 92 | func Get() *Config { 93 | return &config 94 | } 95 | 96 | // TagPattern 定义标签模式及其名称 97 | type TagPattern struct { 98 | Name string `mapstructure:"name"` // 标签名称,用于日志和调试 99 | Pattern string `mapstructure:"pattern"` // 标签的正则表达式模式 100 | } 101 | 102 | // SSMLConfig 存储SSML标签配置 103 | type SSMLConfig struct { 104 | // PreserveTags 包含所有需要保留的标签的正则表达式模式 105 | PreserveTags []TagPattern `mapstructure:"preserve_tags"` 106 | } 107 | 108 | // SSMLProcessor 处理SSML内容 109 | type SSMLProcessor struct { 110 | config *SSMLConfig 111 | patternCache map[string]*regexp.Regexp 112 | } 113 | 114 | // NewSSMLProcessor 从配置对象创建SSMLProcessor 115 | func NewSSMLProcessor(config *SSMLConfig) (*SSMLProcessor, error) { 116 | processor := &SSMLProcessor{ 117 | config: config, 118 | patternCache: make(map[string]*regexp.Regexp), 119 | } 120 | 121 | // 预编译正则表达式 122 | for _, tagPattern := range config.PreserveTags { 123 | regex, err := regexp.Compile(tagPattern.Pattern) 124 | if err != nil { 125 | return nil, fmt.Errorf("编译正则表达式'%s'失败: %w", tagPattern.Name, err) 126 | } 127 | processor.patternCache[tagPattern.Name] = regex 128 | } 129 | 130 | return processor, nil 131 | } 132 | 133 | // EscapeSSML 转义SSML内容,但保留配置的标签 134 | func (p *SSMLProcessor) EscapeSSML(ssml string) string { 135 | // 使用占位符替换标签 136 | placeholders := make(map[string]string) 137 | processedSSML := ssml 138 | 139 | counter := 0 140 | 141 | // 处理所有配置的标签 142 | for name, pattern := range p.patternCache { 143 | processedSSML = pattern.ReplaceAllStringFunc(processedSSML, func(match string) string { 144 | placeholder := fmt.Sprintf("__SSML_PLACEHOLDER_%s_%d__", name, counter) 145 | placeholders[placeholder] = match 146 | counter++ 147 | return placeholder 148 | }) 149 | } 150 | 151 | // 对处理后的文本进行HTML转义 152 | escapedContent := html.EscapeString(processedSSML) 153 | 154 | // 恢复所有标签占位符 155 | for placeholder, tag := range placeholders { 156 | escapedContent = strings.Replace(escapedContent, placeholder, tag, 1) 157 | } 158 | 159 | return escapedContent 160 | } 161 | -------------------------------------------------------------------------------- /internal/http/handlers/pages.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "html/template" 5 | "path/filepath" 6 | 7 | "github.com/gin-gonic/gin" 8 | "tts/internal/config" 9 | ) 10 | 11 | // PagesHandler 处理页面请求 12 | type PagesHandler struct { 13 | templates *template.Template 14 | config *config.Config 15 | } 16 | 17 | // NewPagesHandler 创建一个新的页面处理器 18 | func NewPagesHandler(templatesDir string, cfg *config.Config) (*PagesHandler, error) { 19 | // 解析所有模板文件 20 | templates, err := template.ParseGlob(filepath.Join(templatesDir, "*.html")) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | return &PagesHandler{ 26 | templates: templates, 27 | config: cfg, 28 | }, nil 29 | } 30 | 31 | // HandleIndex 处理首页请求 32 | func (h *PagesHandler) HandleIndex(c *gin.Context) { 33 | // 准备模板数据 34 | data := map[string]interface{}{ 35 | "BasePath": h.config.Server.BasePath, 36 | "DefaultVoice": h.config.TTS.DefaultVoice, 37 | "DefaultRate": h.config.TTS.DefaultRate, 38 | "DefaultPitch": h.config.TTS.DefaultPitch, 39 | } 40 | 41 | // 设置内容类型 42 | c.Header("Content-Type", "text/html; charset=utf-8") 43 | 44 | // 渲染模板 45 | if err := h.templates.ExecuteTemplate(c.Writer, "index.html", data); err != nil { 46 | c.AbortWithStatusJSON(500, gin.H{"error": "模板渲染失败: " + err.Error()}) 47 | return 48 | } 49 | } 50 | 51 | // HandleAPIDoc 处理API文档请求 52 | func (h *PagesHandler) HandleAPIDoc(c *gin.Context) { 53 | // 准备模板数据 54 | data := map[string]interface{}{ 55 | "BasePath": h.config.Server.BasePath, 56 | "DefaultVoice": h.config.TTS.DefaultVoice, 57 | "DefaultRate": h.config.TTS.DefaultRate, 58 | "DefaultPitch": h.config.TTS.DefaultPitch, 59 | "DefaultFormat": h.config.TTS.DefaultFormat, 60 | } 61 | 62 | // 设置内容类型 63 | c.Header("Content-Type", "text/html; charset=utf-8") 64 | 65 | // 渲染模板 66 | if err := h.templates.ExecuteTemplate(c.Writer, "api-doc.html", data); err != nil { 67 | c.AbortWithStatusJSON(500, gin.H{"error": "模板渲染失败: " + err.Error()}) 68 | return 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /internal/http/handlers/tts.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/google/uuid" 7 | "log" 8 | "net/http" 9 | "os" 10 | "os/exec" 11 | "path/filepath" 12 | "strings" 13 | "sync" 14 | "time" 15 | "tts/internal/config" 16 | "tts/internal/models" 17 | "tts/internal/tts" 18 | "tts/internal/utils" 19 | "unicode/utf8" 20 | 21 | "github.com/gin-gonic/gin" 22 | ) 23 | 24 | var cfg = config.Get() 25 | 26 | // truncateForLog 截断文本用于日志显示,同时显示开头和结尾 27 | func truncateForLog(text string, maxLength int) string { 28 | // 先去除换行符 29 | text = strings.ReplaceAll(text, "\n", " ") 30 | text = strings.ReplaceAll(text, "\r", " ") 31 | 32 | runes := []rune(text) 33 | if len(runes) <= maxLength { 34 | return text 35 | } 36 | // 计算开头和结尾各显示多少字符 37 | halfLength := maxLength / 2 38 | return string(runes[:halfLength]) + "..." + string(runes[len(runes)-halfLength:]) 39 | } 40 | 41 | // audioMerge 音频合并 42 | func audioMerge(audioSegments [][]byte) ([]byte, error) { 43 | if len(audioSegments) == 0 { 44 | return nil, fmt.Errorf("没有音频片段可合并") 45 | } 46 | 47 | // 使用 ffmpeg 合并音频 48 | tempDir, err := os.MkdirTemp("", "audio_merge_") 49 | if err != nil { 50 | return nil, err 51 | } 52 | defer os.RemoveAll(tempDir) 53 | 54 | listFile := filepath.Join(tempDir, "concat.txt") 55 | lf, err := os.Create(listFile) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | for i, seg := range audioSegments { 61 | segFile := filepath.Join(tempDir, fmt.Sprintf("seg_%d.mp3", i)) 62 | if err := os.WriteFile(segFile, seg, 0644); err != nil { 63 | return nil, err 64 | } 65 | if _, err := lf.WriteString(fmt.Sprintf("file '%s'\n", segFile)); err != nil { 66 | return nil, err 67 | } 68 | } 69 | lf.Close() 70 | 71 | outputFile := filepath.Join(tempDir, "output.mp3") 72 | 73 | cmd := exec.Command("ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", listFile, "-c", "copy", outputFile) 74 | if err := cmd.Run(); err != nil { 75 | return nil, err 76 | } 77 | 78 | mergedData, err := os.ReadFile(outputFile) 79 | if err != nil { 80 | return nil, err 81 | } 82 | log.Printf("使用ffmpeg合并完成,总大小: %s", formatFileSize(len(mergedData))) 83 | return mergedData, nil 84 | } 85 | 86 | // formatFileSize 格式化文件大小 87 | func formatFileSize(size int) string { 88 | switch { 89 | case size < 1024: 90 | return fmt.Sprintf("%d B", size) 91 | case size < 1024*1024: 92 | return fmt.Sprintf("%.2f KB", float64(size)/1024.0) 93 | case size < 1024*1024*1024: 94 | return fmt.Sprintf("%.2f MB", float64(size)/(1024.0*1024.0)) 95 | default: 96 | return fmt.Sprintf("%.2f GB", float64(size)/(1024.0*1024.0*1024.0)) 97 | } 98 | } 99 | 100 | // TTSHandler 处理TTS请求 101 | type TTSHandler struct { 102 | ttsService tts.Service 103 | config *config.Config 104 | } 105 | 106 | // NewTTSHandler 创建一个新的TTS处理器 107 | func NewTTSHandler(service tts.Service, cfg *config.Config) *TTSHandler { 108 | return &TTSHandler{ 109 | ttsService: service, 110 | config: cfg, 111 | } 112 | } 113 | 114 | // processTTSRequest 处理TTS请求的核心逻辑 115 | func (h *TTSHandler) processTTSRequest(c *gin.Context, req models.TTSRequest, startTime time.Time, parseTime time.Duration, requestType string) { 116 | // 验证必要参数 117 | if req.Text == "" { 118 | log.Print("错误: 未提供文本参数") 119 | c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "必须提供文本参数"}) 120 | return 121 | } 122 | 123 | // 使用默认值填充空白参数 124 | h.fillDefaultValues(&req) 125 | 126 | // 检查文本长度 127 | reqTextLength := utf8.RuneCountInString(req.Text) 128 | if reqTextLength > h.config.TTS.MaxTextLength { 129 | c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "文本长度超过限制"}) 130 | return 131 | } 132 | 133 | // 检查是否需要分段处理 134 | segmentThreshold := h.config.TTS.SegmentThreshold 135 | if reqTextLength > segmentThreshold && reqTextLength <= h.config.TTS.MaxTextLength { 136 | log.Printf("文本长度 %d 超过阈值 %d,使用分段处理", reqTextLength, segmentThreshold) 137 | h.handleSegmentedTTS(c, req) 138 | return 139 | } 140 | 141 | synthStart := time.Now() 142 | resp, err := h.ttsService.SynthesizeSpeech(c.Request.Context(), req) 143 | synthTime := time.Since(synthStart) 144 | log.Printf("TTS合成耗时: %v, 文本长度: %d", synthTime, reqTextLength) 145 | 146 | if err != nil { 147 | log.Printf("TTS合成失败: %v", err) 148 | c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "语音合成失败: " + err.Error()}) 149 | return 150 | } 151 | 152 | // 设置响应 153 | c.Header("Content-Type", "audio/mpeg") 154 | writeStart := time.Now() 155 | if _, err := c.Writer.Write(resp.AudioContent); err != nil { 156 | log.Printf("写入响应失败: %v", err) 157 | return 158 | } 159 | writeTime := time.Since(writeStart) 160 | 161 | // 记录总耗时 162 | totalTime := time.Since(startTime) 163 | log.Printf("%s请求总耗时: %v (解析: %v, 合成: %v, 写入: %v), 音频大小: %s", 164 | requestType, totalTime, parseTime, synthTime, writeTime, formatFileSize(len(resp.AudioContent))) 165 | } 166 | 167 | // fillDefaultValues 填充默认值 168 | func (h *TTSHandler) fillDefaultValues(req *models.TTSRequest) { 169 | if req.Voice == "" { 170 | req.Voice = h.config.TTS.DefaultVoice 171 | } 172 | if req.Rate == "" { 173 | req.Rate = h.config.TTS.DefaultRate 174 | } 175 | if req.Pitch == "" { 176 | req.Pitch = h.config.TTS.DefaultPitch 177 | } 178 | } 179 | 180 | // HandleTTS 处理TTS请求 181 | func (h *TTSHandler) HandleTTS(c *gin.Context) { 182 | switch c.Request.Method { 183 | case http.MethodGet: 184 | h.HandleTTSGet(c) 185 | case http.MethodPost: 186 | h.HandleTTSPost(c) 187 | default: 188 | c.AbortWithStatusJSON(http.StatusMethodNotAllowed, gin.H{"error": "仅支持GET和POST请求"}) 189 | } 190 | } 191 | 192 | // HandleTTSGet 处理GET方式的TTS请求 193 | func (h *TTSHandler) HandleTTSGet(c *gin.Context) { 194 | startTime := time.Now() 195 | 196 | // 从URL参数获取 197 | req := models.TTSRequest{ 198 | Text: c.Query("t"), 199 | Voice: c.Query("v"), 200 | Rate: c.Query("r"), 201 | Pitch: c.Query("p"), 202 | Style: c.Query("s"), 203 | } 204 | 205 | parseTime := time.Since(startTime) 206 | h.processTTSRequest(c, req, startTime, parseTime, "TTS GET") 207 | } 208 | 209 | // HandleTTSPost 处理POST方式的TTS请求 210 | func (h *TTSHandler) HandleTTSPost(c *gin.Context) { 211 | startTime := time.Now() 212 | 213 | // 从POST JSON体或表单数据获取 214 | var req models.TTSRequest 215 | var err error 216 | 217 | if c.ContentType() == "application/json" { 218 | err = c.ShouldBindJSON(&req) 219 | if err != nil { 220 | log.Printf("JSON解析错误: %v", err) 221 | c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "无效的JSON请求"}) 222 | return 223 | } 224 | } else { 225 | err = c.ShouldBind(&req) 226 | if err != nil { 227 | log.Printf("表单解析错误: %v", err) 228 | c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "无法解析表单数据"}) 229 | return 230 | } 231 | } 232 | 233 | parseTime := time.Since(startTime) 234 | h.processTTSRequest(c, req, startTime, parseTime, "TTS POST") 235 | } 236 | 237 | // HandleOpenAITTS 处理OpenAI兼容的TTS请求 238 | func (h *TTSHandler) HandleOpenAITTS(c *gin.Context) { 239 | startTime := time.Now() 240 | 241 | // 只支持POST请求 242 | if c.Request.Method != http.MethodPost { 243 | c.AbortWithStatusJSON(http.StatusMethodNotAllowed, gin.H{"error": "仅支持POST请求"}) 244 | return 245 | } 246 | 247 | // 解析请求 248 | var openaiReq models.OpenAIRequest 249 | if err := c.ShouldBindJSON(&openaiReq); err != nil { 250 | c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "无效的JSON请求: " + err.Error()}) 251 | return 252 | } 253 | 254 | parseTime := time.Since(startTime) 255 | 256 | // 检查必需字段 257 | if openaiReq.Input == "" { 258 | c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "input字段不能为空"}) 259 | return 260 | } 261 | 262 | // 创建内部TTS请求 263 | req := h.convertOpenAIRequest(openaiReq) 264 | 265 | log.Printf("OpenAI TTS请求: model=%s, voice=%s → %s, speed=%.2f → %s, 文本长度=%d", 266 | openaiReq.Model, openaiReq.Voice, req.Voice, openaiReq.Speed, req.Rate, utf8.RuneCountInString(req.Text)) 267 | 268 | h.processTTSRequest(c, req, startTime, parseTime, "OpenAI TTS") 269 | } 270 | 271 | // convertOpenAIRequest 将OpenAI请求转换为内部请求格式 272 | func (h *TTSHandler) convertOpenAIRequest(openaiReq models.OpenAIRequest) models.TTSRequest { 273 | // 映射OpenAI声音到Microsoft声音 274 | msVoice := openaiReq.Voice 275 | if openaiReq.Voice != "" && h.config.TTS.VoiceMapping[openaiReq.Voice] != "" { 276 | msVoice = h.config.TTS.VoiceMapping[openaiReq.Voice] 277 | } 278 | 279 | // 转换速度参数到微软格式 280 | msRate := h.config.TTS.DefaultRate 281 | if openaiReq.Speed != 0 { 282 | speedPercentage := (openaiReq.Speed - 1.0) * 100 283 | if speedPercentage >= 0 { 284 | msRate = fmt.Sprintf("+%.0f", speedPercentage) 285 | } else { 286 | msRate = fmt.Sprintf("%.0f", speedPercentage) 287 | } 288 | } 289 | 290 | return models.TTSRequest{ 291 | Text: openaiReq.Input, 292 | Voice: msVoice, 293 | Rate: msRate, 294 | Pitch: h.config.TTS.DefaultPitch, 295 | Style: openaiReq.Model, 296 | } 297 | } 298 | 299 | // Add this struct to store synthesis results 300 | type sentenceSynthesisResult struct { 301 | index int 302 | length int 303 | audioSize int 304 | content string 305 | duration time.Duration 306 | } 307 | 308 | // Modify the handleSegmentedTTS function to collect and display results in a table 309 | func (h *TTSHandler) handleSegmentedTTS(c *gin.Context, req models.TTSRequest) { 310 | segmentStart := time.Now() 311 | text := req.Text 312 | 313 | // 开始计时:分割文本 314 | splitStart := time.Now() 315 | sentences := splitTextBySentences(text) 316 | segmentCount := len(sentences) 317 | splitTime := time.Since(splitStart) 318 | 319 | log.Printf("分割文本耗时: %v, 文本总长度: %d, 分段数: %d, 平均句子长度: %.2f", 320 | splitTime, utf8.RuneCountInString(text), segmentCount, float64(utf8.RuneCountInString(text))/float64(segmentCount)) 321 | 322 | // 创建用于存储每段音频的切片 323 | results := make([][]byte, segmentCount) 324 | // 创建用于收集合成结果信息的切片 325 | synthResults := make([]sentenceSynthesisResult, segmentCount) 326 | 327 | errChan := make(chan error, 1) 328 | var wg sync.WaitGroup 329 | var synthMutex sync.Mutex 330 | 331 | // 限制并发数量 332 | maxConcurrent := h.config.TTS.MaxConcurrent 333 | semaphore := make(chan struct{}, maxConcurrent) 334 | 335 | // 合成阶段开始时间 336 | synthesisStart := time.Now() 337 | 338 | // 并发处理每一个句子 339 | for i := 0; i < segmentCount; i++ { 340 | wg.Add(1) 341 | go func(index int) { 342 | defer wg.Done() 343 | 344 | select { 345 | case semaphore <- struct{}{}: // 获取信号量 346 | defer func() { <-semaphore }() // 释放信号量 347 | case <-c.Request.Context().Done(): 348 | select { 349 | case errChan <- c.Request.Context().Err(): 350 | default: 351 | } 352 | return 353 | } 354 | 355 | // 创建该句的请求 356 | segReq := models.TTSRequest{ 357 | Text: sentences[index], 358 | Voice: req.Voice, 359 | Rate: req.Rate, 360 | Pitch: req.Pitch, 361 | Style: req.Style, 362 | } 363 | 364 | startTime := time.Now() 365 | // 合成该段音频 366 | resp, err := h.ttsService.SynthesizeSpeech(c.Request.Context(), segReq) 367 | synthDuration := time.Since(startTime) 368 | 369 | if err != nil { 370 | select { 371 | case errChan <- fmt.Errorf("句子 %d 合成失败: %w", index+1, err): 372 | default: 373 | } 374 | return 375 | } 376 | 377 | // 收集合成结果信息,而不是立即打印 378 | result := sentenceSynthesisResult{ 379 | index: index, 380 | length: utf8.RuneCountInString(sentences[index]), 381 | audioSize: len(resp.AudioContent), 382 | content: truncateForLog(sentences[index], 20), 383 | duration: synthDuration, 384 | } 385 | 386 | synthMutex.Lock() 387 | synthResults[index] = result 388 | results[index] = resp.AudioContent 389 | synthMutex.Unlock() 390 | }(i) 391 | } 392 | 393 | // 等待所有goroutine完成或出错 394 | done := make(chan struct{}) 395 | go func() { 396 | wg.Wait() 397 | close(done) 398 | }() 399 | 400 | select { 401 | case <-done: 402 | // 所有goroutine正常完成 403 | case err := <-errChan: 404 | // 发生错误 405 | c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 406 | return 407 | case <-c.Request.Context().Done(): 408 | // 请求被取消 409 | c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "请求被取消"}) 410 | return 411 | } 412 | 413 | // 打印表格格式的合成结果 414 | log.Println("句子合成结果表:") 415 | log.Println("-------------------------------------------------------------") 416 | log.Println("序号 | 长度 | 音频大小 | 耗时 | 内容") 417 | log.Println("-------------------------------------------------------------") 418 | for i := 0; i < segmentCount; i++ { 419 | result := synthResults[i] 420 | log.Printf("#%-3d | %4d | %12s | %10v | %s", 421 | i+1, 422 | result.length, 423 | formatFileSize(result.audioSize), 424 | result.duration.Round(time.Millisecond), 425 | result.content) 426 | } 427 | log.Println("-------------------------------------------------------------") 428 | 429 | // 记录合成总耗时 430 | synthesisTime := time.Since(synthesisStart) 431 | log.Printf("所有分段合成总耗时: %v, 平均每段耗时: %v", 432 | synthesisTime, synthesisTime/time.Duration(segmentCount)) 433 | 434 | // 合并音频 435 | writeStart := time.Now() 436 | audioData, err := audioMerge(results) 437 | if err != nil { 438 | log.Printf("合并音频失败: %v", err) 439 | c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "音频合并失败: " + err.Error()}) 440 | return 441 | } 442 | 443 | // 设置响应内容类型并写入数据 444 | c.Header("Content-Type", "audio/mpeg") 445 | if _, err := c.Writer.Write(audioData); err != nil { 446 | log.Printf("写入响应失败: %v", err) 447 | return 448 | } 449 | 450 | // 记录写入耗时和总耗时 451 | writeTime := time.Since(writeStart) 452 | totalTime := time.Since(segmentStart) 453 | log.Printf("分段TTS请求总耗时: %v (分割: %v, 合成: %v, 写入: %v), 总音频大小: %s", 454 | totalTime, splitTime, synthesisTime, writeTime, formatFileSize(len(audioData))) 455 | } 456 | 457 | // HandleReader 返回 reader 可导入的格式 458 | func (h *TTSHandler) HandleReader(context *gin.Context) { 459 | // 从URL参数获取 460 | req := models.TTSRequest{ 461 | Text: context.Query("t"), 462 | Voice: context.Query("v"), 463 | Rate: context.Query("r"), 464 | Pitch: context.Query("p"), 465 | Style: context.Query("s"), 466 | } 467 | displayName := context.Query("n") 468 | 469 | baseUrl := utils.GetBaseURL(context) 470 | basePath, err := utils.JoinURL(baseUrl, cfg.Server.BasePath) 471 | if err != nil { 472 | context.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 473 | return 474 | } 475 | 476 | // 构建基本URL 477 | urlParams := []string{"t={{java.encodeURI(speakText)}}", "r={{speakSpeed*4}}"} 478 | 479 | // 只有有值的参数才添加 480 | if req.Voice != "" { 481 | urlParams = append(urlParams, fmt.Sprintf("v=%s", req.Voice)) 482 | } 483 | 484 | if req.Pitch != "" { 485 | urlParams = append(urlParams, fmt.Sprintf("p=%s", req.Pitch)) 486 | } 487 | 488 | if req.Style != "" { 489 | urlParams = append(urlParams, fmt.Sprintf("s=%s", req.Style)) 490 | } 491 | 492 | if cfg.TTS.ApiKey != "" { 493 | urlParams = append(urlParams, fmt.Sprintf("api_key=%s", cfg.TTS.ApiKey)) 494 | } 495 | 496 | url := fmt.Sprintf("%s/tts?%s", basePath, strings.Join(urlParams, "&")) 497 | 498 | encoder := json.NewEncoder(context.Writer) 499 | encoder.SetEscapeHTML(false) 500 | context.Status(http.StatusOK) 501 | encoder.Encode(models.ReaderResponse{ 502 | Id: time.Now().Unix(), 503 | Name: displayName, 504 | Url: url, 505 | }) 506 | } 507 | 508 | // HandleIFreeTime 处理IFreeTime应用请求 509 | func (h *TTSHandler) HandleIFreeTime(context *gin.Context) { 510 | // 从URL参数获取 511 | req := models.TTSRequest{ 512 | Voice: context.Query("v"), 513 | Rate: context.Query("r"), 514 | Pitch: context.Query("p"), 515 | Style: context.Query("s"), 516 | } 517 | displayName := context.Query("n") 518 | 519 | // 获取基础URL 520 | baseUrl := utils.GetBaseURL(context) 521 | basePath, err := utils.JoinURL(baseUrl, cfg.Server.BasePath) 522 | if err != nil { 523 | context.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 524 | return 525 | } 526 | 527 | // 构建URL 528 | url := fmt.Sprintf("%s/tts", basePath) 529 | 530 | // 生成随机的唯一ID 531 | ttsConfigID := uuid.New().String() 532 | 533 | // 构建声音列表 534 | var voiceList []models.IFreeTimeVoice 535 | 536 | // 构建请求参数 537 | params := map[string]string{ 538 | "t": "%@", // %@ 是 IFreeTime 中的文本占位符 539 | "v": req.Voice, 540 | "r": req.Rate, 541 | "p": req.Pitch, 542 | "s": req.Style, 543 | } 544 | 545 | // 如果需要API密钥认证,添加到请求参数 546 | if h.config.TTS.ApiKey != "" { 547 | params["api_key"] = h.config.TTS.ApiKey 548 | } 549 | 550 | // 构建响应 551 | response := models.IFreeTimeResponse{ 552 | LoginUrl: "", 553 | MaxWordCount: "", 554 | CustomRules: map[string]interface{}{}, 555 | TtsConfigGroup: "Azure", 556 | TTSName: displayName, 557 | ClassName: "JxdAdvCustomTTS", 558 | TTSConfigID: ttsConfigID, 559 | HttpConfigs: models.IFreeTimeHttpConfig{ 560 | UseCookies: 1, 561 | Headers: map[string]interface{}{}, 562 | }, 563 | VoiceList: voiceList, 564 | TtsHandles: []models.IFreeTimeTtsHandle{ 565 | { 566 | ParamsEx: "", 567 | ProcessType: 1, 568 | MaxPageCount: 1, 569 | NextPageMethod: 1, 570 | Method: 1, 571 | RequestByWebView: 0, 572 | Parser: map[string]interface{}{}, 573 | NextPageParams: map[string]interface{}{}, 574 | Url: url, 575 | Params: params, 576 | HttpConfigs: models.IFreeTimeHttpConfig{ 577 | UseCookies: 1, 578 | Headers: map[string]interface{}{}, 579 | }, 580 | }, 581 | }, 582 | } 583 | 584 | // 设置响应类型 585 | context.Header("Content-Type", "application/json") 586 | context.JSON(http.StatusOK, response) 587 | } 588 | 589 | // splitTextBySentences 将文本按句子分割 590 | func splitTextBySentences(text string) []string { 591 | // 如果文本过短,直接作为一个句子返回 592 | if utf8.RuneCountInString(text) < 100 { 593 | return []string{text} 594 | } 595 | 596 | maxLen := cfg.TTS.MaxSentenceLength 597 | minLen := cfg.TTS.MinSentenceLength 598 | 599 | // 第一次分割:按标点和长度限制分割 600 | sentences := utils.SplitAndFilterEmptyLines(text) 601 | // 第二次处理:合并过短的句子 602 | shortSentences := utils.MergeStringsWithLimit(sentences, minLen, maxLen) 603 | log.Printf("分割后的句子数: %d → %d", len(sentences), len(shortSentences)) 604 | return shortSentences 605 | } 606 | -------------------------------------------------------------------------------- /internal/http/handlers/voices.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | "tts/internal/tts" 6 | 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | // VoicesHandler 处理语音列表请求 11 | type VoicesHandler struct { 12 | ttsService tts.Service 13 | } 14 | 15 | // NewVoicesHandler 创建一个新的语音列表处理器 16 | func NewVoicesHandler(service tts.Service) *VoicesHandler { 17 | return &VoicesHandler{ 18 | ttsService: service, 19 | } 20 | } 21 | 22 | // HandleVoices 处理语音列表请求 23 | func (h *VoicesHandler) HandleVoices(c *gin.Context) { 24 | // 从查询参数中获取语言筛选 25 | locale := c.Query("locale") 26 | 27 | // 获取语音列表 28 | voices, err := h.ttsService.ListVoices(c.Request.Context(), locale) 29 | if err != nil { 30 | c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "获取语音列表失败: " + err.Error()}) 31 | return 32 | } 33 | 34 | // 返回JSON响应 35 | c.JSON(http.StatusOK, voices) 36 | } 37 | -------------------------------------------------------------------------------- /internal/http/middleware/auth.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | // OpenAIAuth 中间件验证 OpenAI API 请求的令牌 10 | func OpenAIAuth(apiToken string) gin.HandlerFunc { 11 | return func(c *gin.Context) { 12 | // 如果没有配置令牌,跳过验证 13 | if apiToken == "" { 14 | c.Next() 15 | return 16 | } 17 | 18 | // 获取请求头中的 Authorization 19 | authHeader := c.GetHeader("Authorization") 20 | if authHeader == "" { 21 | c.AbortWithStatusJSON(401, gin.H{"error": "未提供授权令牌"}) 22 | return 23 | } 24 | 25 | // 验证格式是否为 "Bearer {token}" 26 | parts := strings.SplitN(authHeader, " ", 2) 27 | if len(parts) != 2 || parts[0] != "Bearer" { 28 | c.AbortWithStatusJSON(401, gin.H{"error": "授权格式无效"}) 29 | return 30 | } 31 | 32 | // 验证令牌是否正确 33 | if parts[1] != apiToken { 34 | c.AbortWithStatusJSON(401, gin.H{"error": "令牌无效"}) 35 | return 36 | } 37 | 38 | // 令牌验证通过,继续处理请求 39 | c.Next() 40 | } 41 | } 42 | 43 | // TTSAuth 是用于验证 TTS API 接口的中间件 44 | func TTSAuth(apiKey string) gin.HandlerFunc { 45 | return func(c *gin.Context) { 46 | // 从查询参数中获取 api_key 47 | queryKey := c.Query("api_key") 48 | 49 | // 如果 apiKey 配置为空字符串,表示不需要验证 50 | if apiKey != "" && queryKey != apiKey { 51 | c.AbortWithStatusJSON(401, gin.H{"error": "未授权访问: 无效的 API 密钥"}) 52 | return 53 | } 54 | 55 | // 验证通过,继续处理请求 56 | c.Next() 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /internal/http/middleware/cors.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import "github.com/gin-gonic/gin" 4 | 5 | // CORS 处理跨域资源共享 6 | func CORS() gin.HandlerFunc { 7 | return func(c *gin.Context) { 8 | // 设置CORS响应头 9 | c.Header("Access-Control-Allow-Origin", "*") 10 | c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") 11 | c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization") 12 | 13 | // 如果是预检请求,直接返回200 14 | if c.Request.Method == "OPTIONS" { 15 | c.AbortWithStatus(200) 16 | return 17 | } 18 | 19 | // 继续下一个处理器 20 | c.Next() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /internal/http/middleware/logger.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "log" 5 | "time" 6 | 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | // Logger 是一个HTTP中间件,记录请求的详细信息 11 | func Logger() gin.HandlerFunc { 12 | return func(c *gin.Context) { 13 | start := time.Now() 14 | 15 | // 处理请求 16 | c.Next() 17 | 18 | // 记录请求信息 19 | duration := time.Since(start) 20 | log.Printf("[%s] %s %s %d %s", 21 | c.Request.Method, 22 | c.Request.URL.Path, 23 | c.ClientIP(), 24 | c.Writer.Status(), 25 | duration, 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /internal/http/routes/routes.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "tts/internal/config" 5 | "tts/internal/http/handlers" 6 | "tts/internal/http/middleware" 7 | "tts/internal/tts" 8 | "tts/internal/tts/microsoft" 9 | 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | // SetupRoutes 配置所有API路由 14 | func SetupRoutes(cfg *config.Config, ttsService tts.Service) (*gin.Engine, error) { 15 | // 创建Gin路由 16 | router := gin.New() 17 | 18 | // 创建处理器 19 | ttsHandler := handlers.NewTTSHandler(ttsService, cfg) 20 | voicesHandler := handlers.NewVoicesHandler(ttsService) 21 | 22 | // 创建页面处理器 23 | pagesHandler, err := handlers.NewPagesHandler("./web/templates", cfg) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | // 应用中间件 29 | router.Use(middleware.Logger()) // 日志中间件 30 | router.Use(middleware.CORS()) // CORS中间件 31 | 32 | // 应用基础路径前缀 33 | var baseRouter gin.IRoutes 34 | if cfg.Server.BasePath != "" { 35 | baseRouter = router.Group(cfg.Server.BasePath) 36 | } else { 37 | baseRouter = router 38 | } 39 | 40 | // 设置静态文件服务 41 | baseRouter.Static("/static", "./web/static") 42 | 43 | // 设置主页路由 44 | baseRouter.GET("/", pagesHandler.HandleIndex) 45 | 46 | // 设置API文档路由 47 | baseRouter.GET("/api-doc", pagesHandler.HandleAPIDoc) 48 | 49 | // 设置TTS API路由 - 添加认证中间件 50 | 51 | baseRouter.POST("/tts", middleware.TTSAuth(cfg.TTS.ApiKey), ttsHandler.HandleTTS) 52 | baseRouter.GET("/tts", middleware.TTSAuth(cfg.TTS.ApiKey), ttsHandler.HandleTTS) 53 | baseRouter.GET("/reader.json", middleware.TTSAuth(cfg.TTS.ApiKey), ttsHandler.HandleReader) 54 | baseRouter.GET("ifreetime.json", middleware.TTSAuth(cfg.TTS.ApiKey), ttsHandler.HandleIFreeTime) 55 | 56 | // 设置语音列表API路由 57 | baseRouter.GET("/voices", voicesHandler.HandleVoices) 58 | 59 | // 设置OpenAI兼容接口的处理器,添加验证中间件 60 | openAIHandler := middleware.OpenAIAuth(cfg.OpenAI.ApiKey) 61 | baseRouter.POST("/v1/audio/speech", openAIHandler, ttsHandler.HandleOpenAITTS) 62 | baseRouter.POST("/audio/speech", openAIHandler, ttsHandler.HandleOpenAITTS) 63 | 64 | return router, nil 65 | } 66 | 67 | // InitializeServices 初始化所有服务 68 | func InitializeServices(cfg *config.Config) (tts.Service, error) { 69 | // 创建Microsoft TTS客户端 70 | ttsClient := microsoft.NewClient(cfg) 71 | 72 | return ttsClient, nil 73 | } 74 | -------------------------------------------------------------------------------- /internal/http/server/app.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | "time" 11 | "tts/internal/config" 12 | "tts/internal/http/routes" 13 | ) 14 | 15 | // App 表示整个TTS应用程序 16 | type App struct { 17 | server *Server 18 | cfg *config.Config 19 | } 20 | 21 | // NewApp 创建一个新的应用程序实例 22 | func NewApp(configPath string) (*App, error) { 23 | // 加载配置 24 | cfg, err := config.Load(configPath) 25 | if err != nil { 26 | return nil, fmt.Errorf("加载配置失败: %w", err) 27 | } 28 | 29 | // 初始化服务 30 | ttsService, err := routes.InitializeServices(cfg) 31 | if err != nil { 32 | return nil, fmt.Errorf("初始化服务失败: %w", err) 33 | } 34 | 35 | // 设置Gin路由 36 | router, err := routes.SetupRoutes(cfg, ttsService) 37 | if err != nil { 38 | return nil, fmt.Errorf("设置路由失败: %w", err) 39 | } 40 | 41 | // 创建HTTP服务器 42 | server := New(cfg, router) 43 | 44 | return &App{ 45 | server: server, 46 | cfg: cfg, 47 | }, nil 48 | } 49 | 50 | // Start 启动应用程序 51 | func (a *App) Start() error { 52 | // 创建一个错误通道 53 | errChan := make(chan error, 1) 54 | 55 | // 创建一个退出信号通道 56 | quit := make(chan os.Signal, 1) 57 | signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) 58 | 59 | // 在一个goroutine中启动服务器 60 | go func() { 61 | log.Printf("启动TTS服务,监听端口 %d...\n", a.cfg.Server.Port) 62 | errChan <- a.server.Start() 63 | }() 64 | 65 | // 等待退出信号或错误 66 | select { 67 | case err := <-errChan: 68 | return err 69 | case <-quit: 70 | // 创建一个超时上下文用于优雅关闭 71 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 72 | defer cancel() 73 | 74 | // 尝试优雅关闭服务器 75 | if err := a.server.Shutdown(ctx); err != nil { 76 | return fmt.Errorf("服务器关闭出错: %w", err) 77 | } 78 | 79 | log.Println("服务器已优雅关闭") 80 | return nil 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /internal/http/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/gin-gonic/gin" 7 | "tts/internal/config" 8 | ) 9 | 10 | // Server 封装HTTP服务器 11 | type Server struct { 12 | router *gin.Engine 13 | basePath string 14 | port int 15 | } 16 | 17 | // New 创建新的HTTP服务器 18 | func New(cfg *config.Config, router *gin.Engine) *Server { 19 | return &Server{ 20 | router: router, 21 | basePath: cfg.Server.BasePath, 22 | port: cfg.Server.Port, 23 | } 24 | } 25 | 26 | // Start 启动HTTP服务器 27 | func (s *Server) Start() error { 28 | addr := fmt.Sprintf(":%d", s.port) 29 | return s.router.Run(addr) 30 | } 31 | 32 | // Shutdown 优雅关闭服务器 33 | func (s *Server) Shutdown(ctx context.Context) error { 34 | fmt.Println("正在关闭HTTP服务器...") 35 | // Gin 本身没有提供 Shutdown 方法,需要手动实现 36 | // 这里可以添加自定义的关闭逻辑 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /internal/models/tts.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // TTSRequest 表示一个语音合成请求 4 | type TTSRequest struct { 5 | Text string `json:"text"` // 要转换的文本 6 | Voice string `json:"voice"` // 语音ID 7 | Rate string `json:"rate"` // 语速 (-100% 到 +100%) 8 | Pitch string `json:"pitch"` // 语调 (-100% 到 +100%) 9 | Style string `json:"style"` // 说话风格 10 | } 11 | 12 | // TTSResponse 表示一个语音合成响应 13 | type TTSResponse struct { 14 | AudioContent []byte `json:"audio_content"` // 音频数据 15 | ContentType string `json:"content_type"` // MIME类型 16 | CacheHit bool `json:"cache_hit"` // 是否命中缓存 17 | } 18 | 19 | // OpenAIRequest OpenAI TTS请求结构体 20 | type OpenAIRequest struct { 21 | Model string `json:"model"` 22 | Input string `json:"input"` 23 | Voice string `json:"voice"` 24 | Speed float64 `json:"speed"` 25 | } 26 | 27 | // ReaderResponse reader 响应结构体 28 | type ReaderResponse struct { 29 | Id int64 `json:"id"` 30 | Name string `json:"name"` 31 | Url string `json:"url"` 32 | } 33 | 34 | // IFreeTimeResponse IFreeTime应用配置响应 35 | type IFreeTimeResponse struct { 36 | LoginUrl string `json:"loginUrl"` 37 | MaxWordCount string `json:"maxWordCount"` 38 | CustomRules map[string]interface{} `json:"customRules"` 39 | TtsConfigGroup string `json:"ttsConfigGroup"` 40 | TTSName string `json:"_TTSName"` 41 | ClassName string `json:"_ClassName"` 42 | TTSConfigID string `json:"_TTSConfigID"` 43 | HttpConfigs IFreeTimeHttpConfig `json:"httpConfigs"` 44 | VoiceList []IFreeTimeVoice `json:"voiceList"` 45 | TtsHandles []IFreeTimeTtsHandle `json:"ttsHandles"` 46 | } 47 | 48 | // IFreeTimeHttpConfig HTTP配置 49 | type IFreeTimeHttpConfig struct { 50 | UseCookies int `json:"useCookies"` 51 | Headers map[string]interface{} `json:"headers"` 52 | } 53 | 54 | // IFreeTimeVoice 语音配置 55 | type IFreeTimeVoice struct { 56 | Name string `json:"name"` 57 | Display string `json:"display"` 58 | } 59 | 60 | // IFreeTimeTtsHandle TTS处理配置 61 | type IFreeTimeTtsHandle struct { 62 | ParamsEx string `json:"paramsEx"` 63 | ProcessType int `json:"processType"` 64 | MaxPageCount int `json:"maxPageCount"` 65 | NextPageMethod int `json:"nextPageMethod"` 66 | Method int `json:"method"` 67 | RequestByWebView int `json:"requestByWebView"` 68 | Parser map[string]interface{} `json:"parser"` 69 | NextPageParams map[string]interface{} `json:"nextPageParams"` 70 | Url string `json:"url"` 71 | Params map[string]string `json:"params"` 72 | HttpConfigs IFreeTimeHttpConfig `json:"httpConfigs"` 73 | } 74 | -------------------------------------------------------------------------------- /internal/models/voice.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // Voice 表示一个语音合成声音 4 | type Voice struct { 5 | Name string `json:"name"` // 语音唯一标识符 6 | DisplayName string `json:"display_name"` // 语音显示名称 7 | LocalName string `json:"local_name"` // 本地化名称 8 | ShortName string `json:"short_name"` // 简称,例如 zh-CN-XiaoxiaoNeural 9 | Gender string `json:"gender"` // 性别: Female, Male 10 | Locale string `json:"locale"` // 语言区域, 如 zh-CN 11 | LocaleName string `json:"locale_name"` // 语言区域显示名称,如 中文(中国) 12 | StyleList []string `json:"style_list,omitempty"` // 支持的说话风格列表 13 | SampleRateHertz string `json:"sample_rate_hertz"` // 采样率 14 | } 15 | -------------------------------------------------------------------------------- /internal/tts/microsoft/client.go: -------------------------------------------------------------------------------- 1 | package microsoft 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "log" 11 | "net/http" 12 | "strings" 13 | "sync" 14 | "time" 15 | 16 | "tts/internal/config" 17 | "tts/internal/models" 18 | "tts/internal/utils" 19 | ) 20 | 21 | const ( 22 | userAgent = "okhttp/4.5.0" 23 | voicesEndpoint = "https://%s.tts.speech.microsoft.com/cognitiveservices/voices/list" 24 | ttsEndpoint = "https://%s.tts.speech.microsoft.com/cognitiveservices/v1" 25 | ssmlTemplate = ` 26 | 27 | 28 | 29 | %s 30 | 31 | 32 | 33 | ` 34 | ) 35 | 36 | // Client 是Microsoft TTS API的客户端实现 37 | type Client struct { 38 | defaultVoice string 39 | defaultRate string 40 | defaultPitch string 41 | defaultFormat string 42 | maxTextLength int 43 | httpClient *http.Client 44 | voicesCache []models.Voice 45 | voicesCacheMu sync.RWMutex 46 | voicesCacheExpiry time.Time 47 | 48 | // 端点和认证信息 49 | endpoint map[string]interface{} 50 | endpointMu sync.RWMutex 51 | endpointExpiry time.Time 52 | ssmProcessor *config.SSMLProcessor 53 | } 54 | 55 | // NewClient 创建一个新的Microsoft TTS客户端 56 | func NewClient(cfg *config.Config) *Client { 57 | // 从Viper配置中创建SSML处理器 58 | ssmProcessor, err := config.NewSSMLProcessor(&cfg.SSML) 59 | if err != nil { 60 | log.Fatalf("创建SSML处理器失败: %v", err) 61 | } 62 | client := &Client{ 63 | defaultVoice: cfg.TTS.DefaultVoice, 64 | defaultRate: cfg.TTS.DefaultRate, 65 | defaultPitch: cfg.TTS.DefaultPitch, 66 | defaultFormat: cfg.TTS.DefaultFormat, 67 | maxTextLength: cfg.TTS.MaxTextLength, 68 | httpClient: &http.Client{ 69 | Timeout: time.Duration(cfg.TTS.RequestTimeout) * time.Second, 70 | }, 71 | voicesCacheExpiry: time.Time{}, // 初始时缓存为空 72 | endpointExpiry: time.Time{}, // 初始时端点为空 73 | ssmProcessor: ssmProcessor, 74 | } 75 | 76 | return client 77 | } 78 | 79 | // getEndpoint 获取或刷新认证端点 80 | func (c *Client) getEndpoint(ctx context.Context) (map[string]interface{}, error) { 81 | c.endpointMu.RLock() 82 | if !c.endpointExpiry.IsZero() && time.Now().Before(c.endpointExpiry) && c.endpoint != nil { 83 | endpoint := c.endpoint 84 | c.endpointMu.RUnlock() 85 | return endpoint, nil 86 | } 87 | c.endpointMu.RUnlock() 88 | 89 | // 获取新的端点信息 90 | endpoint, err := utils.GetEndpoint() 91 | if err != nil { 92 | log.Printf("获取认证信息失败: %v\n", err) 93 | return nil, err 94 | } 95 | log.Printf("获取认证信息成功: %v\n", endpoint) 96 | 97 | // 从 jwt 中解析出到期时间 exp 98 | jwt := endpoint["t"].(string) 99 | exp := utils.GetExp(jwt) 100 | if exp == 0 { 101 | return nil, errors.New("jwt 中缺少 exp 字段") 102 | } 103 | expTime := time.Unix(exp, 0) 104 | log.Println("jwt 距到期时间:", expTime.Sub(time.Now())) 105 | 106 | // 更新缓存 107 | c.endpointMu.Lock() 108 | c.endpoint = endpoint 109 | c.endpointExpiry = expTime.Add(-1 * time.Minute) // 提前1分钟过期 110 | c.endpointMu.Unlock() 111 | 112 | return endpoint, nil 113 | } 114 | 115 | // ListVoices 获取可用的语音列表 116 | func (c *Client) ListVoices(ctx context.Context, locale string) ([]models.Voice, error) { 117 | // 检查缓存是否有效 118 | c.voicesCacheMu.RLock() 119 | if !c.voicesCacheExpiry.IsZero() && time.Now().Before(c.voicesCacheExpiry) && len(c.voicesCache) > 0 { 120 | voices := c.voicesCache 121 | c.voicesCacheMu.RUnlock() 122 | 123 | // 如果指定了locale,则过滤结果 124 | if locale != "" { 125 | var filtered []models.Voice 126 | for _, voice := range voices { 127 | if strings.HasPrefix(voice.Locale, locale) { 128 | filtered = append(filtered, voice) 129 | } 130 | } 131 | return filtered, nil 132 | } 133 | return voices, nil 134 | } 135 | c.voicesCacheMu.RUnlock() 136 | 137 | // 缓存无效,需要从API获取 138 | endpoint, err := c.getEndpoint(ctx) 139 | if err != nil { 140 | return nil, err 141 | } 142 | 143 | url := fmt.Sprintf(voicesEndpoint, endpoint["r"]) 144 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 145 | if err != nil { 146 | return nil, err 147 | } 148 | 149 | // 使用新的认证方式 150 | req.Header.Set("Authorization", endpoint["t"].(string)) 151 | 152 | resp, err := c.httpClient.Do(req) 153 | if err != nil { 154 | return nil, err 155 | } 156 | defer resp.Body.Close() 157 | 158 | if resp.StatusCode != http.StatusOK { 159 | body, _ := io.ReadAll(resp.Body) 160 | return nil, fmt.Errorf("API error: %s, status: %d", string(body), resp.StatusCode) 161 | } 162 | 163 | var msVoices []MicrosoftVoice 164 | if err := json.NewDecoder(resp.Body).Decode(&msVoices); err != nil { 165 | return nil, err 166 | } 167 | 168 | // 转换为通用模型 169 | voices := make([]models.Voice, len(msVoices)) 170 | for i, v := range msVoices { 171 | voices[i] = models.Voice{ 172 | Name: v.Name, 173 | DisplayName: v.DisplayName, 174 | LocalName: v.LocalName, 175 | ShortName: v.ShortName, 176 | Gender: v.Gender, 177 | Locale: v.Locale, 178 | LocaleName: v.LocaleName, 179 | StyleList: v.StyleList, 180 | SampleRateHertz: v.SampleRateHertz, // 直接使用字符串,无需转换 181 | } 182 | } 183 | 184 | // 更新缓存 185 | c.voicesCacheMu.Lock() 186 | c.voicesCache = voices 187 | c.voicesCacheExpiry = time.Now().Add(24 * time.Hour) // 缓存24小时 188 | c.voicesCacheMu.Unlock() 189 | 190 | // 如果指定了locale,则过滤结果 191 | if locale != "" { 192 | var filtered []models.Voice 193 | for _, voice := range voices { 194 | if strings.HasPrefix(voice.Locale, locale) { 195 | filtered = append(filtered, voice) 196 | } 197 | } 198 | return filtered, nil 199 | } 200 | 201 | return voices, nil 202 | } 203 | 204 | // SynthesizeSpeech 将文本转换为语音 205 | func (c *Client) SynthesizeSpeech(ctx context.Context, req models.TTSRequest) (*models.TTSResponse, error) { 206 | resp, err := c.createTTSRequest(ctx, req) 207 | if err != nil { 208 | return nil, err 209 | } 210 | defer resp.Body.Close() 211 | 212 | // 读取音频数据 213 | audio, err := io.ReadAll(resp.Body) 214 | if err != nil { 215 | return nil, err 216 | } 217 | 218 | return &models.TTSResponse{ 219 | AudioContent: audio, 220 | ContentType: "audio/mpeg", 221 | CacheHit: false, 222 | }, nil 223 | } 224 | 225 | // createTTSRequest 创建并执行TTS请求,返回HTTP响应 226 | func (c *Client) createTTSRequest(ctx context.Context, req models.TTSRequest) (*http.Response, error) { 227 | // 参数验证 228 | if req.Text == "" { 229 | return nil, errors.New("文本不能为空") 230 | } 231 | 232 | if len(req.Text) > c.maxTextLength { 233 | return nil, fmt.Errorf("文本长度超过限制 (%d > %d)", len(req.Text), c.maxTextLength) 234 | } 235 | 236 | // 使用默认值填充空白参数 237 | voice := req.Voice 238 | if voice == "" { 239 | voice = c.defaultVoice 240 | } 241 | 242 | style := req.Style 243 | if req.Style == "" { 244 | style = "general" 245 | } 246 | 247 | rate := req.Rate 248 | if rate == "" { 249 | rate = c.defaultRate 250 | } 251 | 252 | pitch := req.Pitch 253 | if pitch == "" { 254 | pitch = c.defaultPitch 255 | } 256 | 257 | // 提取语言 258 | locale := "zh-CN" // 默认 259 | parts := strings.Split(voice, "-") 260 | if len(parts) >= 2 { 261 | locale = parts[0] + "-" + parts[1] 262 | } 263 | 264 | // 对文本进行HTML转义,防止XML解析错误 265 | escapedText := c.ssmProcessor.EscapeSSML(req.Text) 266 | 267 | // 准备SSML内容 268 | ssml := fmt.Sprintf(ssmlTemplate, locale, voice, style, rate, pitch, escapedText) 269 | 270 | // 获取端点信息 271 | endpoint, err := c.getEndpoint(ctx) 272 | if err != nil { 273 | return nil, err 274 | } 275 | 276 | // 准备请求 277 | url := fmt.Sprintf(ttsEndpoint, endpoint["r"]) 278 | reqBody := bytes.NewBufferString(ssml) 279 | 280 | httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, reqBody) 281 | if err != nil { 282 | return nil, err 283 | } 284 | 285 | httpReq.Header.Set("Authorization", endpoint["t"].(string)) 286 | httpReq.Header.Set("Content-Type", "application/ssml+xml") 287 | httpReq.Header.Set("X-Microsoft-OutputFormat", c.defaultFormat) 288 | httpReq.Header.Set("User-Agent", userAgent) 289 | 290 | // 发送请求 291 | resp, err := c.httpClient.Do(httpReq) 292 | 293 | if err != nil { 294 | return nil, err 295 | } 296 | 297 | if resp.StatusCode != http.StatusOK { 298 | // 获取响应体以便调试 299 | body, _ := io.ReadAll(resp.Body) 300 | resp.Body.Close() 301 | log.Printf("TTS API错误: %s, 状态码: %d", string(body), resp.StatusCode) 302 | return nil, fmt.Errorf("TTS API错误: %s, 状态码: %d", string(body), resp.StatusCode) 303 | } 304 | 305 | return resp, nil 306 | } 307 | -------------------------------------------------------------------------------- /internal/tts/microsoft/models.go: -------------------------------------------------------------------------------- 1 | package microsoft 2 | 3 | // MicrosoftVoice 表示Microsoft TTS服务中的一个语音 4 | type MicrosoftVoice struct { 5 | Name string `json:"Name"` 6 | DisplayName string `json:"DisplayName"` 7 | LocalName string `json:"LocalName"` 8 | ShortName string `json:"ShortName"` 9 | Gender string `json:"Gender"` 10 | Locale string `json:"Locale"` 11 | LocaleName string `json:"LocaleName"` 12 | StyleList []string `json:"StyleList,omitempty"` 13 | SampleRateHertz string `json:"SampleRateHertz"` 14 | VoiceType string `json:"VoiceType"` 15 | Status string `json:"Status"` 16 | } 17 | 18 | // SSMLRequest 表示发送给Microsoft TTS服务的SSML请求 19 | type SSMLRequest struct { 20 | XMLHeader string 21 | Voice string 22 | Language string 23 | Rate string 24 | Pitch string 25 | Text string 26 | } 27 | 28 | // FormatContentTypeMap 定义音频格式到MIME类型的映射 29 | var FormatContentTypeMap = map[string]string{ 30 | "raw-16khz-16bit-mono-pcm": "audio/pcm", 31 | "raw-8khz-8bit-mono-mulaw": "audio/basic", 32 | "riff-8khz-8bit-mono-alaw": "audio/alaw", 33 | "riff-8khz-8bit-mono-mulaw": "audio/mulaw", 34 | "riff-16khz-16bit-mono-pcm": "audio/wav", 35 | "audio-16khz-128kbitrate-mono-mp3": "audio/mp3", 36 | "audio-16khz-64kbitrate-mono-mp3": "audio/mp3", 37 | "audio-16khz-32kbitrate-mono-mp3": "audio/mp3", 38 | "raw-24khz-16bit-mono-pcm": "audio/pcm", 39 | "riff-24khz-16bit-mono-pcm": "audio/wav", 40 | "audio-24khz-160kbitrate-mono-mp3": "audio/mp3", 41 | "audio-24khz-96kbitrate-mono-mp3": "audio/mp3", 42 | "audio-24khz-48kbitrate-mono-mp3": "audio/mp3", 43 | "ogg-24khz-16bit-mono-opus": "audio/ogg", 44 | "webm-24khz-16bit-mono-opus": "audio/webm", 45 | } 46 | -------------------------------------------------------------------------------- /internal/tts/service.go: -------------------------------------------------------------------------------- 1 | package tts 2 | 3 | import ( 4 | "context" 5 | "tts/internal/models" 6 | ) 7 | 8 | // Service 定义TTS服务接口 9 | type Service interface { 10 | // ListVoices 获取可用的语音列表 11 | ListVoices(ctx context.Context, locale string) ([]models.Voice, error) 12 | 13 | // SynthesizeSpeech 将文本转换为语音 14 | SynthesizeSpeech(ctx context.Context, req models.TTSRequest) (*models.TTSResponse, error) 15 | } 16 | -------------------------------------------------------------------------------- /internal/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/rand" 6 | "crypto/sha256" 7 | "encoding/base64" 8 | "encoding/json" 9 | "fmt" 10 | "github.com/gin-gonic/gin" 11 | "net/http" 12 | "net/url" 13 | "strings" 14 | "time" 15 | "unicode/utf8" 16 | 17 | "github.com/google/uuid" 18 | "github.com/sirupsen/logrus" 19 | ) 20 | 21 | var ( 22 | log = logrus.New() 23 | client = &http.Client{} 24 | ) 25 | 26 | const ( 27 | endpointURL = "https://dev.microsofttranslator.com/apps/endpoint?api-version=1.0" 28 | userAgent = "okhttp/4.5.0" 29 | clientVersion = "4.0.530a 5fe1dc6c" 30 | homeGeographicRegion = "zh-Hans-CN" 31 | voiceDecodeKey = "oik6PdDdMnOXemTbwvMn9de/h9lFnfBaCWbGMMZqqoSaQaqUOqjVGm5NqsmjcBI1x+sS9ugjB55HEJWRiFXYFw==" 32 | ) 33 | 34 | func generateUserID() string { 35 | chars := "abcdef0123456789" 36 | result := make([]byte, 16) 37 | for i := 0; i < 16; i++ { 38 | randIndex := make([]byte, 1) 39 | if _, err := rand.Read(randIndex); err != nil { 40 | return "" 41 | } 42 | result[i] = chars[randIndex[0]%uint8(len(chars))] 43 | } 44 | return string(result) 45 | } 46 | 47 | // GetEndpoint 获取语音合成服务的端点信息 48 | func GetEndpoint() (map[string]interface{}, error) { 49 | signature := Sign(endpointURL) 50 | userId := generateUserID() 51 | traceId := uuid.New().String() 52 | headers := map[string]string{ 53 | "Accept-Language": "zh-Hans", 54 | "X-ClientVersion": clientVersion, 55 | "X-UserId": userId, 56 | "X-HomeGeographicRegion": homeGeographicRegion, 57 | "X-ClientTraceId": traceId, 58 | "X-MT-Signature": signature, 59 | "User-Agent": userAgent, 60 | "Content-Type": "application/json; charset=utf-8", 61 | "Content-Length": "0", 62 | "Accept-Encoding": "gzip", 63 | } 64 | req, err := http.NewRequest("POST", endpointURL, nil) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | for k, v := range headers { 70 | req.Header.Set(k, v) 71 | } 72 | 73 | headerJson, err := json.Marshal(&headers) 74 | fmt.Printf("GetEndpoint -> url: %s, headers: %v\n", endpointURL, string(headerJson)) 75 | 76 | resp, err := client.Do(req) 77 | if err != nil { 78 | log.Error("failed to do request: ", err) 79 | return nil, err 80 | } 81 | 82 | defer resp.Body.Close() 83 | 84 | if resp.StatusCode != http.StatusOK { 85 | log.Error("failed to get endpoint, status code: ", resp.StatusCode) 86 | return nil, fmt.Errorf("failed to get endpoint, status code: %d", resp.StatusCode) 87 | } 88 | 89 | var result map[string]interface{} 90 | err = json.NewDecoder(resp.Body).Decode(&result) 91 | if err != nil { 92 | return nil, err 93 | } 94 | 95 | return result, nil 96 | } 97 | 98 | // Sign 生成签名 99 | func Sign(urlStr string) string { 100 | u := strings.Split(urlStr, "://")[1] 101 | encodedUrl := url.QueryEscape(u) 102 | uuidStr := strings.ReplaceAll(uuid.New().String(), "-", "") 103 | formattedDate := strings.ToLower(time.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05")) + "gmt" 104 | bytesToSign := fmt.Sprintf("MSTranslatorAndroidApp%s%s%s", encodedUrl, formattedDate, uuidStr) 105 | bytesToSign = strings.ToLower(bytesToSign) 106 | decode, _ := base64.StdEncoding.DecodeString(voiceDecodeKey) 107 | hash := hmac.New(sha256.New, decode) 108 | hash.Write([]byte(bytesToSign)) 109 | secretKey := hash.Sum(nil) 110 | signBase64 := base64.StdEncoding.EncodeToString(secretKey) 111 | return fmt.Sprintf("MSTranslatorAndroidApp::%s::%s::%s", signBase64, formattedDate, uuidStr) 112 | } 113 | 114 | // SplitAndFilterEmptyLines 拆分文本并过滤掉空行 115 | func SplitAndFilterEmptyLines(text string) []string { 116 | // 按换行符拆分 117 | lines := strings.Split(text, "\n") 118 | var result []string 119 | 120 | for _, line := range lines { 121 | trimmed := strings.TrimSpace(line) 122 | if trimmed != "" { 123 | result = append(result, trimmed) 124 | } 125 | } 126 | return result 127 | } 128 | 129 | // MergeStringsWithLimit 会将字符串切片依次累加,直到总长度 ≥ minLen。 130 | // 但如果再合并下一段后会超过 maxLen,则提前结束本段合并,放入结果。 131 | // 然后继续新的一段合并。 132 | func MergeStringsWithLimit(strs []string, minLen int, maxLen int) []string { 133 | var result []string 134 | 135 | for i := 0; i < len(strs); { 136 | // 如果已经没有更多段落,直接退出 137 | if i >= len(strs) { 138 | break 139 | } 140 | 141 | // 从当前段开始合并 142 | currentBuilder := strings.Builder{} 143 | currentBuilder.WriteString(strs[i]) 144 | i++ 145 | 146 | for i < len(strs) { 147 | currentLen := utf8.RuneCountInString(currentBuilder.String()) 148 | // 如果当前已达(或超过) minLen,先行结束本段合并 149 | if currentLen >= minLen { 150 | break 151 | } 152 | 153 | // 检查添加下一个段落后是否会超过 1.2 × minLen 154 | nextLen := utf8.RuneCountInString(strs[i]) 155 | if currentLen+nextLen > int(float64(minLen)*1.2) { 156 | // 加上下一个会超标,则结束合并 157 | break 158 | } 159 | 160 | // 如果未超标,则继续合并这个段 161 | currentBuilder.WriteString("\n") 162 | currentBuilder.WriteString(strs[i]) 163 | i++ 164 | } 165 | 166 | // 本段合并结束,加入结果 167 | result = append(result, currentBuilder.String()) 168 | } 169 | 170 | return result 171 | } 172 | 173 | // GetBaseURL 返回基础 URL,包括方案和主机,但不包括路径和查询参数 174 | func GetBaseURL(c *gin.Context) string { 175 | scheme := "http" 176 | if c.Request.TLS != nil || c.Request.Header.Get("X-Forwarded-Proto") == "https" { 177 | scheme = "https" 178 | } 179 | 180 | return fmt.Sprintf("%s://%s", scheme, c.Request.Host) 181 | } 182 | 183 | // JoinURL 安全地拼接基础 URL 和相对路径 184 | func JoinURL(baseURL, relativePath string) (string, error) { 185 | base, err := url.Parse(baseURL) 186 | if err != nil { 187 | return "", err 188 | } 189 | 190 | rel, err := url.Parse(relativePath) 191 | if err != nil { 192 | return "", err 193 | } 194 | 195 | return base.ResolveReference(rel).String(), nil 196 | } 197 | 198 | func GetExp(s string) int64 { 199 | // 解析 JWT 200 | parts := strings.Split(s, ".") 201 | if len(parts) != 3 { 202 | return 0 203 | } 204 | 205 | // 解码负载部分 206 | payload, err := base64.RawURLEncoding.DecodeString(parts[1]) 207 | if err != nil { 208 | return 0 209 | } 210 | 211 | // 解析 JSON 212 | var data map[string]interface{} 213 | if err := json.Unmarshal(payload, &data); err != nil { 214 | return 0 215 | } 216 | 217 | exp, ok := data["exp"].(float64) 218 | if !ok { 219 | return 0 220 | } 221 | 222 | return int64(exp) 223 | } 224 | -------------------------------------------------------------------------------- /script/build.sh: -------------------------------------------------------------------------------- 1 | docker buildx build --platform linux/amd64,linux/arm64 --tag zuoban/zb-tts .. --push -------------------------------------------------------------------------------- /web/static/icons/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | T 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /web/static/js/app.js: -------------------------------------------------------------------------------- 1 | function saveApiKey() { 2 | const apiKeyInput = document.getElementById('api-key'); 3 | const apiKeyGroup = document.getElementById('api-key-group'); 4 | 5 | if (apiKeyInput) { 6 | const apiKey = apiKeyInput.value.trim(); 7 | if (apiKey) { 8 | // 保存到localStorage 9 | localStorage.setItem('apiKey', apiKey); 10 | 11 | // 显示成功消息 12 | showCustomAlert('API Key 已成功保存', 'success'); 13 | 14 | // 隐藏API Key输入区域 15 | if (apiKeyGroup) { 16 | apiKeyGroup.classList.add('hidden'); 17 | } 18 | } else { 19 | showCustomAlert('API Key 已清空', 'success'); 20 | localStorage.setItem('apiKey', '') 21 | // 隐藏API Key输入区域 22 | if (apiKeyGroup) { 23 | apiKeyGroup.classList.add('hidden'); 24 | } 25 | } 26 | } 27 | } 28 | 29 | // 保存表单数据到localStorage 30 | function saveFormData() { 31 | const textInput = document.getElementById('text'); 32 | const voiceSelect = document.getElementById('voice'); 33 | const styleSelect = document.getElementById('style'); 34 | const rateInput = document.getElementById('rate'); 35 | const pitchInput = document.getElementById('pitch'); 36 | 37 | // 保存文本内容 38 | if (textInput && textInput.value) { 39 | localStorage.setItem('ttsText', textInput.value); 40 | } 41 | 42 | // 保存语音选择 43 | if (voiceSelect && voiceSelect.value) { 44 | localStorage.setItem('ttsVoice', voiceSelect.value); 45 | } 46 | 47 | // 保存风格选择 48 | if (styleSelect && styleSelect.value) { 49 | localStorage.setItem('ttsStyle', styleSelect.value); 50 | } 51 | 52 | // 保存语速 53 | if (rateInput && rateInput.value) { 54 | localStorage.setItem('ttsRate', rateInput.value); 55 | } 56 | 57 | // 保存语调 58 | if (pitchInput && pitchInput.value) { 59 | localStorage.setItem('ttsPitch', pitchInput.value); 60 | } 61 | } 62 | 63 | // 从localStorage加载表单数据 64 | function loadFormData() { 65 | const textInput = document.getElementById('text'); 66 | const voiceSelect = document.getElementById('voice'); 67 | const styleSelect = document.getElementById('style'); 68 | const rateInput = document.getElementById('rate'); 69 | const rateValue = document.getElementById('rateValue'); 70 | const pitchInput = document.getElementById('pitch'); 71 | const pitchValue = document.getElementById('pitchValue'); 72 | 73 | // 加载文本内容 74 | const savedText = localStorage.getItem('ttsText'); 75 | if (savedText && textInput) { 76 | textInput.value = savedText; 77 | // 更新字符计数 78 | if (document.getElementById('charCount')) { 79 | document.getElementById('charCount').textContent = savedText.length; 80 | } 81 | } 82 | 83 | // 加载语速 84 | const savedRate = localStorage.getItem('ttsRate'); 85 | if (savedRate && rateInput) { 86 | rateInput.value = savedRate; 87 | if (rateValue) { 88 | rateValue.textContent = savedRate + '%'; 89 | } 90 | } 91 | 92 | // 加载语调 93 | const savedPitch = localStorage.getItem('ttsPitch'); 94 | if (savedPitch && pitchInput) { 95 | pitchInput.value = savedPitch; 96 | if (pitchValue) { 97 | pitchValue.textContent = savedPitch + '%'; 98 | } 99 | } 100 | 101 | // 保存风格选择的值,以便在语音加载后使用 102 | const savedStyle = localStorage.getItem('ttsStyle'); 103 | 104 | // 加载语音选择(在语音列表加载完成后处理) 105 | const savedVoice = localStorage.getItem('ttsVoice'); 106 | if (savedVoice && voiceSelect) { 107 | // 在initVoicesList完成后设置 108 | const voiceLoadInterval = setInterval(() => { 109 | if (voiceSelect.options.length > 0 && voiceSelect.options[0].value !== "loading") { 110 | for (let i = 0; i < voiceSelect.options.length; i++) { 111 | if (voiceSelect.options[i].value === savedVoice) { 112 | voiceSelect.selectedIndex = i; 113 | // 触发change事件以更新风格选项 114 | const event = new Event('change'); 115 | voiceSelect.dispatchEvent(event); 116 | 117 | // 在语音选择更新后,设置保存的风格 118 | // 使用setTimeout确保风格选项已经更新 119 | setTimeout(() => { 120 | if (savedStyle && styleSelect && styleSelect.options.length > 0) { 121 | for (let j = 0; j < styleSelect.options.length; j++) { 122 | if (styleSelect.options[j].value === savedStyle) { 123 | styleSelect.selectedIndex = j; 124 | break; 125 | } 126 | } 127 | } 128 | }, 100); 129 | 130 | break; 131 | } 132 | } 133 | clearInterval(voiceLoadInterval); 134 | } 135 | }, 100); 136 | } else { 137 | // 如果没有保存的语音选择,但有保存的风格,直接尝试设置风格 138 | if (savedStyle && styleSelect) { 139 | const styleLoadInterval = setInterval(() => { 140 | if (styleSelect.options.length > 0) { 141 | for (let i = 0; i < styleSelect.options.length; i++) { 142 | if (styleSelect.options[i].value === savedStyle) { 143 | styleSelect.selectedIndex = i; 144 | break; 145 | } 146 | } 147 | clearInterval(styleLoadInterval); 148 | } 149 | }, 100); 150 | } 151 | } 152 | } 153 | 154 | document.addEventListener('DOMContentLoaded', function () { 155 | // 获取DOM元素 156 | const textInput = document.getElementById('text'); 157 | const voiceSelect = document.getElementById('voice'); 158 | const styleSelect = document.getElementById('style'); 159 | const rateInput = document.getElementById('rate'); 160 | const rateValue = document.getElementById('rateValue'); 161 | const pitchInput = document.getElementById('pitch'); 162 | const pitchValue = document.getElementById('pitchValue'); 163 | const apiKeyInput = document.getElementById('api-key'); 164 | const apiKeyGroup = document.getElementById('api-key-group'); 165 | const speakButton = document.getElementById('speak'); 166 | const downloadButton = document.getElementById('download'); 167 | const copyLinkButton = document.getElementById('copyLink'); 168 | const copyHttpTtsLinkButton = document.getElementById('copyHttpTtsLink'); 169 | const copyIfreetimeLinkButton = document.getElementById('copyIfreetimeLink'); // 新增元素引用 170 | const audioPlayer = document.getElementById('audioPlayer'); 171 | const resultSection = document.getElementById('resultSection'); 172 | const charCount = document.getElementById('charCount'); 173 | const toggleApiKeyButton = document.getElementById('toggle-api-key'); 174 | const apiKeyStatus = document.getElementById('api-key-status'); 175 | const apiKeySaveButton = document.getElementById('apvi-key-save'); 176 | const togglePasswordButton = document.getElementById('toggle-password'); 177 | 178 | // 保存最后一个音频URL 179 | let lastAudioUrl = ''; 180 | // 存储语音数据 181 | let voicesData = []; 182 | 183 | // 初始化 184 | initVoicesList(); 185 | initEventListeners(); 186 | loadApiKeyFromLocalStorage(); // 加载API Key 187 | loadFormData(); // 加载表单数据 188 | 189 | // 更新字符计数 190 | textInput.addEventListener('input', function () { 191 | charCount.textContent = this.value.length; 192 | // 保存文本内容 193 | localStorage.setItem('ttsText', this.value); 194 | }); 195 | 196 | // 更新语速值显示 197 | rateInput.addEventListener('input', function () { 198 | const value = this.value; 199 | rateValue.textContent = value + '%'; 200 | // 保存语速 201 | localStorage.setItem('ttsRate', value); 202 | }); 203 | 204 | // 更新语调值显示 205 | pitchInput.addEventListener('input', function () { 206 | const value = this.value; 207 | pitchValue.textContent = value + '%'; 208 | // 保存语调 209 | localStorage.setItem('ttsPitch', value); 210 | }); 211 | 212 | // 语音选择变化时更新可用风格 213 | voiceSelect.addEventListener('change', function () { 214 | updateStyleOptions(); 215 | // 保存语音选择 216 | localStorage.setItem('ttsVoice', this.value); 217 | }); 218 | 219 | // 添加风格选择变化事件 220 | styleSelect.addEventListener('change', function () { 221 | // 保存风格选择 222 | localStorage.setItem('ttsStyle', this.value); 223 | }); 224 | 225 | // 获取可用语音列表 226 | async function initVoicesList() { 227 | try { 228 | const response = await fetch(`${config.basePath}/voices`); 229 | if (!response.ok) throw new Error('获取语音列表失败'); 230 | 231 | voicesData = await response.json(); 232 | 233 | // 清空并重建选项 234 | voiceSelect.innerHTML = ''; 235 | 236 | // 如果还在加载,使用带有动画的加载提示 237 | if (voicesData.length === 0) { 238 | const option = document.createElement('option'); 239 | option.value = "loading"; 240 | option.textContent = "加载中"; 241 | option.className = "loading-text"; 242 | voiceSelect.appendChild(option); 243 | return; 244 | } 245 | 246 | // 按语言和名称分组 247 | const voicesByLocale = {}; 248 | 249 | voicesData.forEach(voice => { 250 | if (!voicesByLocale[voice.locale]) { 251 | voicesByLocale[voice.locale] = []; 252 | } 253 | voicesByLocale[voice.locale].push(voice); 254 | }); 255 | 256 | // 创建选项组 257 | for (const locale in voicesByLocale) { 258 | const optgroup = document.createElement('optgroup'); 259 | optgroup.label = voicesByLocale[locale][0].locale_name; 260 | 261 | voicesByLocale[locale].forEach(voice => { 262 | const option = document.createElement('option'); 263 | option.value = voice.short_name; 264 | option.textContent = `${voice.local_name || voice.display_name} (${voice.gender})`; 265 | 266 | // 如果是默认语音则选中 267 | if (voice.short_name === config.defaultVoice) { 268 | option.selected = true; 269 | } 270 | 271 | optgroup.appendChild(option); 272 | }); 273 | 274 | voiceSelect.appendChild(optgroup); 275 | } 276 | 277 | // 初始化风格列表 278 | updateStyleOptions(); 279 | } catch (error) { 280 | console.error('获取语音列表失败:', error); 281 | voiceSelect.innerHTML = ''; 282 | } 283 | } 284 | 285 | // 更新风格选项 286 | function updateStyleOptions() { 287 | // 清空风格选择 288 | styleSelect.innerHTML = ''; 289 | 290 | // 获取当前选中的语音 291 | const selectedVoice = voiceSelect.value; 292 | const voiceData = voicesData.find(v => v.short_name === selectedVoice); 293 | 294 | if (!voiceData || !voiceData.style_list || voiceData.style_list.length === 0) { 295 | // 如果没有可用风格,添加默认选项 296 | const option = document.createElement('option'); 297 | option.value = "general"; 298 | option.textContent = "普通"; 299 | styleSelect.appendChild(option); 300 | return; 301 | } 302 | 303 | // 添加清空选项 304 | const emptyOption = document.createElement('option'); 305 | emptyOption.value = ""; 306 | emptyOption.textContent = "-- 无风格 --"; 307 | styleSelect.appendChild(emptyOption); 308 | 309 | // 添加可用风格选项 310 | voiceData.style_list.forEach(style => { 311 | const option = document.createElement('option'); 312 | option.value = style; 313 | option.textContent = style; 314 | 315 | // 如果是默认风格则选中 316 | if (style === config.defaultStyle || 317 | (!config.defaultStyle && style === "general")) { 318 | option.selected = true; 319 | } 320 | 321 | styleSelect.appendChild(option); 322 | }); 323 | 324 | // 在风格选项更新后,尝试恢复保存的风格设置 325 | const savedStyle = localStorage.getItem('ttsStyle'); 326 | if (savedStyle) { 327 | for (let i = 0; i < styleSelect.options.length; i++) { 328 | if (styleSelect.options[i].value === savedStyle) { 329 | styleSelect.selectedIndex = i; 330 | break; 331 | } 332 | } 333 | } 334 | } 335 | 336 | // 初始化事件监听器 337 | function initEventListeners() { 338 | // 转换按钮点击事件 339 | speakButton.addEventListener('click', generateSpeech); 340 | 341 | // 下载按钮点击事件 342 | downloadButton.addEventListener('click', function () { 343 | if (lastAudioUrl) { 344 | const a = document.createElement('a'); 345 | a.href = lastAudioUrl; 346 | a.download = 'speech.mp3'; 347 | document.body.appendChild(a); 348 | a.click(); 349 | document.body.removeChild(a); 350 | } 351 | }); 352 | 353 | // 复制链接按钮点击事件 354 | copyLinkButton.addEventListener('click', function () { 355 | if (lastAudioUrl) { 356 | // 获取完整的URL,包括域名部分 357 | const fullUrl = new URL(lastAudioUrl, window.location.origin).href; 358 | copyToClipboard(fullUrl); 359 | } 360 | }); 361 | 362 | // 复制HttpTTS链接按钮点击事件 363 | copyHttpTtsLinkButton.addEventListener('click', function () { 364 | const text = "{{java.encodeURI(speakText)}}"; 365 | const voice = voiceSelect.value; 366 | const displayName = voiceSelect.options[voiceSelect.selectedIndex].text; 367 | const style = styleSelect.value; 368 | const rate = "{{speakSpeed*4}}" 369 | const pitch = pitchInput.value; 370 | const apiKey = apiKeyInput.value.trim(); 371 | 372 | // 构建HttpTTS链接 373 | let httpTtsLink = `${window.location.origin}${config.basePath}/reader.json?&v=${voice}&r=${rate}&p=${pitch}&n=${displayName}`; 374 | 375 | // 只有当style不为空时才添加 376 | if (style) { 377 | httpTtsLink += `&s=${style}`; 378 | } 379 | 380 | // 添加API Key参数(如果有) 381 | if (apiKey) { 382 | httpTtsLink += `&api_key=${apiKey}`; 383 | } 384 | 385 | window.open(httpTtsLink, '_blank') 386 | }); 387 | 388 | // 复制爱阅记链接按钮点击事件 389 | copyIfreetimeLinkButton.addEventListener('click', function () { 390 | const voice = voiceSelect.value; 391 | const displayName = voiceSelect.options[voiceSelect.selectedIndex].text; 392 | const style = styleSelect.value; 393 | const rate = rateInput.value 394 | const pitch = pitchInput.value; 395 | const apiKey = apiKeyInput.value.trim(); 396 | 397 | // 构建爱阅记链接 398 | let ifreetimeLink = `${window.location.origin}${config.basePath}/ifreetime.json?&v=${voice}&r=${rate}&p=${pitch}&n=${displayName}`; 399 | 400 | // 只有当style不为空时才添加 401 | if (style) { 402 | ifreetimeLink += `&s=${style}`; 403 | } 404 | 405 | // 添加API Key参数(如果有) 406 | if (apiKey) { 407 | ifreetimeLink += `&api_key=${apiKey}`; 408 | } 409 | 410 | window.open(ifreetimeLink, '_blank') 411 | }); 412 | 413 | // 显示/隐藏API Key区域的按钮事件 414 | if (toggleApiKeyButton) { 415 | toggleApiKeyButton.addEventListener('click', function () { 416 | if (apiKeyGroup) { 417 | apiKeyGroup.classList.toggle('hidden'); 418 | 419 | // 如果是显示操作,聚焦到输入框 420 | if (!apiKeyGroup.classList.contains('hidden') && apiKeyInput) { 421 | apiKeyInput.focus(); 422 | } 423 | } 424 | }); 425 | } 426 | 427 | // API Key显示/隐藏功能 428 | if (togglePasswordButton) { 429 | togglePasswordButton.addEventListener('click', function () { 430 | const type = apiKeyInput.getAttribute('type') === 'password' ? 'text' : 'password'; 431 | apiKeyInput.setAttribute('type', type); 432 | 433 | // 更新图标 434 | if (type === 'password') { 435 | this.innerHTML = ` 436 | 437 | 438 | `; 439 | } else { 440 | this.innerHTML = ` 441 | 442 | `; 443 | } 444 | }); 445 | } 446 | 447 | // 按Enter键保存API Key 448 | if (apiKeyInput) { 449 | apiKeyInput.addEventListener('keydown', function (event) { 450 | if (event.key === 'Enter') { 451 | event.preventDefault(); 452 | saveApiKey(); // 直接调用全局保存函数 453 | } 454 | }); 455 | } 456 | 457 | // 增强音频播放器 458 | enhanceAudioPlayer(); 459 | } 460 | 461 | // 生成语音 462 | async function generateSpeech() { 463 | const text = textInput.value.trim(); 464 | if (!text) { 465 | showCustomAlert('请输入要转换的文本', 'warning'); 466 | return; 467 | } 468 | 469 | const voice = voiceSelect.value; 470 | const style = styleSelect.value; 471 | const rate = rateInput.value; 472 | const pitch = pitchInput.value; 473 | const apiKey = apiKeyInput.value.trim(); 474 | 475 | // 保存表单数据 476 | saveFormData(); 477 | 478 | // 禁用按钮,显示加载状态 479 | speakButton.disabled = true; 480 | speakButton.textContent = '生成中...'; 481 | 482 | try { 483 | // 构建URL参数 484 | const params = new URLSearchParams({ 485 | t: text, 486 | v: voice, 487 | r: rate, 488 | p: pitch 489 | }); 490 | 491 | // 只有当style不为空时才添加 492 | if (style) { 493 | params.append('s', style); 494 | } 495 | 496 | // 添加API Key参数(如果有) 497 | if (apiKey) { 498 | params.append('api_key', apiKey); 499 | } 500 | 501 | const url = `${config.basePath}/tts?${params.toString()}`; 502 | 503 | // 使用fetch发送请求以便捕获HTTP状态码 504 | const response = await fetch(url); 505 | 506 | if (response.status === 401) { 507 | // 显示API Key输入框 508 | apiKeyGroup.classList.remove('hidden'); 509 | showCustomAlert('请输入有效的API Key以继续操作', 'error'); 510 | throw new Error('需要API Key授权'); 511 | } 512 | 513 | if (!response.ok) { 514 | throw new Error(`HTTP错误: ${response.status}`); 515 | } 516 | 517 | // 获取音频blob 518 | const blob = await response.blob(); 519 | const audioUrl = URL.createObjectURL(blob); 520 | 521 | // 更新音频播放器 522 | audioPlayer.src = audioUrl; 523 | lastAudioUrl = url; // 保存原始URL用于下载和复制链接 524 | 525 | // 显示结果区域 526 | resultSection.classList.remove('hidden'); 527 | 528 | // 播放音频 529 | audioPlayer.play(); 530 | } catch (error) { 531 | console.error('生成语音失败:', error); 532 | if (error.message !== '需要API Key授权') { 533 | showCustomAlert('生成语音失败,请重试', 'error'); 534 | } 535 | } finally { 536 | // 恢复按钮状态 537 | speakButton.disabled = false; 538 | speakButton.textContent = '转换为语音'; 539 | } 540 | } 541 | 542 | // 保存API Key到localStorage 543 | function saveApiKeyToLocalStorage(apiKey) { 544 | console.log('Saving API Key to localStorage'); // 添加调试日志 545 | if (apiKey) { 546 | localStorage.setItem('apiKey', apiKey); 547 | } else { 548 | localStorage.removeItem('apiKey'); 549 | // 如果清除了API Key,显示输入区域 550 | if (apiKeyGroup) { 551 | apiKeyGroup.classList.remove('hidden'); 552 | } 553 | } 554 | } 555 | 556 | // 从localStorage加载API Key 557 | function loadApiKeyFromLocalStorage() { 558 | const apiKey = localStorage.getItem('apiKey'); 559 | 560 | if (apiKey && apiKeyInput) { 561 | apiKeyInput.value = apiKey; 562 | 563 | // 显示已保存状态 564 | if (apiKeyStatus) { 565 | apiKeyStatus.textContent = 'API Key 已保存'; 566 | apiKeyStatus.className = 'api-key-status valid'; 567 | apiKeyStatus.classList.remove('hidden'); 568 | } 569 | } 570 | } 571 | 572 | // 自定义音频播放器增强 573 | function enhanceAudioPlayer() { 574 | // 监听音频播放器出现在DOM中 575 | const observer = new MutationObserver((mutations) => { 576 | mutations.forEach((mutation) => { 577 | if (mutation.addedNodes.length) { 578 | const audioPlayer = document.getElementById('audioPlayer'); 579 | if (audioPlayer && !audioPlayer.dataset.enhanced) { 580 | // 标记为已增强,避免重复处理 581 | audioPlayer.dataset.enhanced = 'true'; 582 | 583 | // 为音频播放器添加额外样式 584 | audioPlayer.addEventListener('play', () => { 585 | audioPlayer.classList.add('playing'); 586 | // 可以添加播放时的视觉反馈 587 | resultSection.classList.add('active-playback'); 588 | }); 589 | 590 | audioPlayer.addEventListener('pause', () => { 591 | audioPlayer.classList.remove('playing'); 592 | resultSection.classList.remove('active-playback'); 593 | }); 594 | } 595 | } 596 | }); 597 | }); 598 | 599 | // 开始观察DOM变化 600 | observer.observe(document.body, {childList: true, subtree: true}); 601 | } 602 | 603 | // 复制内容到剪贴板的通用函数 604 | function copyToClipboard(text) { 605 | let success = false; 606 | navigator.clipboard.writeText(text).then(() => { 607 | showCustomAlert('链接已复制到剪贴板', 'success'); 608 | success = true; 609 | }).catch(err => { 610 | console.error('复制失败:', err); 611 | // 兼容处理 612 | const textArea = document.createElement('textarea'); 613 | textArea.value = text; 614 | document.body.appendChild(textArea); 615 | textArea.focus(); 616 | textArea.select(); 617 | 618 | try { 619 | document.execCommand('copy'); 620 | showCustomAlert('链接已复制到剪贴板', 'success'); 621 | success = true; 622 | } catch (err) { 623 | console.error('复制失败:', err); 624 | shwoCustomAlert('复制失败', 'error'); 625 | success = false; 626 | } 627 | 628 | document.body.removeChild(textArea); 629 | }); 630 | return success; 631 | } 632 | 633 | // 添加通知函数 634 | function showNotification(message, type = 'info', duration = 3000) { 635 | // 移除任何现有通知 636 | const existingNotifications = document.querySelectorAll('.api-key-notification'); 637 | existingNotifications.forEach(notification => { 638 | document.body.removeChild(notification); 639 | }); 640 | 641 | // 创建通知元素 642 | const notification = document.createElement('div'); 643 | notification.className = `api-key-notification ${type}`; 644 | 645 | // 添加图标 646 | let icon = ''; 647 | switch (type) { 648 | case 'success': 649 | icon = ''; 650 | break; 651 | case 'error': 652 | icon = ''; 653 | break; 654 | case 'warning': 655 | icon = ''; 656 | break; 657 | default: 658 | icon = ''; 659 | } 660 | 661 | notification.innerHTML = `${icon}${message}`; 662 | 663 | // 添加到页面 664 | document.body.appendChild(notification); 665 | 666 | // 显示动画 667 | setTimeout(() => { 668 | notification.classList.add('show'); 669 | }, 10); 670 | 671 | // 设置自动关闭 672 | setTimeout(() => { 673 | notification.classList.remove('show'); 674 | setTimeout(() => { 675 | if (notification.parentNode) { 676 | document.body.removeChild(notification); 677 | } 678 | }, 300); 679 | }, duration); 680 | } 681 | 682 | // 添加自定义alert函数到全局范围 683 | window.showCustomAlert = showCustomAlert; 684 | }); 685 | 686 | // 自定义alert函数 687 | function showCustomAlert(message, type = 'info', title = '', duration = 3000) { 688 | // 获取或创建通知容器 689 | let container = document.getElementById('custom-alert-container'); 690 | if (!container) { 691 | container = document.createElement('div'); 692 | container.id = 'custom-alert-container'; 693 | container.className = 'custom-alert-container'; 694 | document.body.appendChild(container); 695 | } 696 | 697 | // 创建通知元素 698 | const alert = document.createElement('div'); 699 | alert.className = `custom-alert ${type}`; 700 | 701 | // 根据类型设置图标 702 | let iconSvg = ''; 703 | switch (type) { 704 | case 'success': 705 | iconSvg = ''; 706 | break; 707 | case 'error': 708 | iconSvg = ''; 709 | break; 710 | case 'warning': 711 | iconSvg = ''; 712 | break; 713 | default: // info 714 | iconSvg = ''; 715 | } 716 | 717 | // 构建通知内容 718 | alert.innerHTML = ` 719 |
720 | ${iconSvg} 721 |
722 |
723 | ${title ? `

${title}

` : ''} 724 |

${message}

725 |
726 | 731 |
732 | `; 733 | 734 | // 添加到容器 735 | container.appendChild(alert); 736 | 737 | // 添加关闭事件 738 | const closeBtn = alert.querySelector('.custom-alert-close'); 739 | closeBtn.addEventListener('click', () => { 740 | removeAlert(alert); 741 | }); 742 | 743 | // 动画效果 744 | setTimeout(() => { 745 | alert.classList.add('show'); 746 | 747 | // 进度条动画 748 | const progress = alert.querySelector('.custom-alert-progress::after'); 749 | if (progress) { 750 | progress.style.animation = `progress ${duration}ms linear forwards`; 751 | } 752 | }, 10); 753 | 754 | // 自动关闭 755 | const timeout = setTimeout(() => { 756 | removeAlert(alert); 757 | }, duration); 758 | 759 | // 清除函数 760 | function removeAlert(element) { 761 | element.classList.remove('show'); 762 | setTimeout(() => { 763 | if (element.parentNode) { 764 | element.parentNode.removeChild(element); 765 | } 766 | }, 300); 767 | clearTimeout(timeout); 768 | } 769 | 770 | // 返回alert对象,以便可以手动控制 771 | return { 772 | element: alert, 773 | close: () => removeAlert(alert) 774 | }; 775 | } 776 | 777 | // 替换全局的alert函数(可选,谨慎使用) 778 | const originalAlert = window.alert; 779 | window.alert = function (message) { 780 | showCustomAlert(message, 'info'); 781 | }; 782 | -------------------------------------------------------------------------------- /web/templates/api-doc.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | API文档 - TTS服务 7 | 8 | 9 | 10 | 319 | 320 | 321 | 322 |
323 |
324 |
325 |
326 |
327 |
328 |
329 |
330 |
331 |
332 |
333 | 334 |
335 |
336 |
337 |

TTS服务 API文档

338 |

快速、高质量的文本转语音API服务

339 | 340 | 341 | 360 |
361 | 362 |
363 |
364 |

API概述

365 |

TTS服务API提供了简单而强大的方式将文本转换为自然语音。我们支持多种语言和声音,并允许您调节语速、语调以适应不同场景需求。

366 |

基础URL: {{.BasePath}}

367 |

所有API请求均使用HTTP协议,返回标准HTTP状态码表示请求结果。

368 |
369 | 370 | 371 |
372 |

文本转语音 API

373 | 374 |

端点

375 | GET {{.BasePath}}/tts 376 | 377 |

参数

378 |
379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 |
参数类型必选描述
tstring要转换的文本(需要进行URL编码)
vstring语音名称,使用short_name格式,默认: {{.DefaultVoice}}。可通过/voices接口获取所有可用语音
rstring语速调整,范围: -100%到100%,默认: {{.DefaultRate}}。正值加快语速,负值减慢语速
pstring语调调整,范围: -100%到100%,默认: {{.DefaultPitch}}。正值提高语调,负值降低语调
ostring输出音频格式,默认: {{.DefaultFormat}}。详见下方支持的格式列表
sstring情感风格,可用值取决于所选语音的style_list属性。例如:"cheerful"、"sad"等
427 |
428 | 429 |

示例请求

430 |
curl "{{.BasePath}}/tts?t=%E4%BD%A0%E5%A5%BD%EF%BC%8C%E4%B8%96%E7%95%8C&v=zh-CN-XiaoxiaoNeural&r=0%25&p=0%25"
431 | 432 |

另一个示例(带情感风格)

433 |
curl "{{.BasePath}}/tts?t=%E4%BB%8A%E5%A4%A9%E5%A4%A9%E6%B0%94%E7%9C%9F%E5%A5%BD&v=zh-CN-XiaoxiaoNeural&s=cheerful"
434 | 435 |

响应

436 |

返回音频文件,内容类型取决于请求的输出格式。正常响应状态码为200。

437 | 438 |

错误响应

439 |

如果请求参数有误或服务出现问题,将返回对应的HTTP错误码和错误消息。

440 |
441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 |
状态码描述
400参数错误或缺失必要参数
404请求的资源不存在
500服务器内部错误
463 |
464 |
465 | 466 |
467 |

获取可用语音 API

468 | 469 |

端点

470 | GET {{.BasePath}}/voices 471 | 472 |

参数

473 |
474 | 475 | 476 | 477 | 478 | 479 | 480 | 481 | 482 | 483 | 484 | 485 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 |
参数类型必选描述
localestring筛选特定语言的语音,例如:zh-CN(中文)、en-US(英文)
genderstring筛选特定性别的语音,可选值:Male(男性)、Female(女性)
498 |
499 | 500 |

示例请求

501 |
curl "{{.BasePath}}/voices?locale=zh-CN&gender=Female"
502 | 503 |

响应

504 |

返回JSON格式的可用语音列表:

505 |
[
506 |   {
507 |     "name": "Microsoft Server Speech Text to Speech Voice (zh-CN, XiaoxiaoNeural)",
508 |     "display_name": "Xiaoxiao",
509 |     "local_name": "晓晓",
510 |     "short_name": "zh-CN-XiaoxiaoNeural",
511 |     "gender": "Female",
512 |     "locale": "zh-CN",
513 |     "locale_name": "中文(中国)",
514 |     "style_list": ["cheerful", "sad", "angry", "fearful", "disgruntled"]
515 |   },
516 |   ...
517 | ]
518 |

响应字段说明:

519 |
    520 |
  • name:语音的完整名称
  • 521 |
  • display_name:显示用名称(拉丁字符)
  • 522 |
  • local_name:本地化名称
  • 523 |
  • short_name:简短名称(用于API调用的v参数)
  • 524 |
  • gender:性别(Male或Female)
  • 525 |
  • locale:语言代码
  • 526 |
  • locale_name:语言本地化名称
  • 527 |
  • style_list:支持的情感风格列表(如有)
  • 528 |
529 |
530 | 531 |
532 |

兼容OpenAI接口 API

533 | 534 |

语音合成

535 | POST {{.BasePath}}/v1/audio/speech 536 | 537 |

请求体 (JSON)

538 |
539 | 540 | 541 | 542 | 543 | 544 | 545 | 546 | 547 | 548 | 549 | 550 | 551 | 552 | 553 | 554 | 555 | 556 | 557 | 558 | 559 | 560 | 561 | 562 | 563 | 564 | 565 | 566 | 567 | 568 | 569 | 570 | 571 | 572 | 573 | 574 |
参数类型必选描述
modelstring当前仅支持值: "tts-1"
inputstring要转换的文本内容
voicestring声音名称,使用Microsoft语音格式,例如:ja-JP-KeitaNeural、zh-CN-XiaoxiaoNeural
speednumber语速调整,范围: 0.5到2.0,默认: 1.0
575 |
576 | 577 |

示例请求

578 |
curl -X POST "{{.BasePath}}/v1/audio/speech" \
579 |   -H "Content-Type: application/json" \
580 |   -d '{
581 |     "model": "tts-1",
582 |     "input": "你好,世界!",
583 |     "voice": "zh-CN-XiaoxiaoNeural"
584 |   }'
585 | 586 |

另一个示例(带速度调整)

587 |
curl -X POST "{{.BasePath}}/v1/audio/speech" \
588 |   -H "Content-Type: application/json" \
589 |   -d '{
590 |     "model": "tts-1",
591 |     "input": "こんにちは、世界!",
592 |     "voice": "ja-JP-NanamiNeural",
593 |     "speed": 1.2
594 |   }'
595 | 596 |

响应

597 |

返回音频文件,内容类型取决于请求的输出格式。正常响应状态码为200。

598 | 599 |

错误响应

600 |

如果请求有误,将返回JSON格式的错误信息:

601 |
{
602 |   "error": {
603 |     "message": "错误信息描述",
604 |     "type": "错误类型",
605 |     "code": "错误代码"
606 |   }
607 | }
608 |
609 | 610 |
611 |

支持的输出格式

612 |
613 | 614 | 615 | 616 | 617 | 618 | 619 | 620 | 621 | 622 | 623 | 624 | 625 | 626 | 627 | 628 | 629 | 630 | 631 | 632 | 633 | 634 | 635 | 636 | 637 | 638 | 639 | 640 | 641 | 642 | 643 | 644 | 645 | 646 | 647 | 648 | 649 | 650 | 651 | 652 | 653 | 654 |
格式名称描述
audio-16khz-32kbitrate-mono-mp3MP3格式,16kHz, 32kbps
audio-16khz-64kbitrate-mono-mp3MP3格式,16kHz, 64kbps
audio-16khz-128kbitrate-mono-mp3MP3格式,16kHz, 128kbps
audio-24khz-48kbitrate-mono-mp3MP3格式,24kHz, 48kbps
audio-24khz-96kbitrate-mono-mp3MP3格式,24kHz, 96kbps
audio-24khz-160kbitrate-mono-mp3MP3格式,24kHz, 160kbps
riff-16khz-16bit-mono-pcmWAV格式,16kHz
riff-24khz-16bit-mono-pcmWAV格式,24kHz
655 |
656 |
657 |
658 | 659 | 662 |
663 |
664 | 665 | 736 | 737 | 738 | -------------------------------------------------------------------------------- /web/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 文本转语音 - TTS服务 7 | 8 | 9 | 10 | 11 | 969 | 970 | 971 | 972 |
973 |
974 |
975 |
976 | 977 |
978 |
979 |
980 |

文本转语音 (TTS)

981 |

将文本转换为自然流畅的语音

982 | 983 | 984 | 1007 |
1008 | 1009 |
1010 |
1011 |
1012 | 1020 |
1021 | 1022 | 1023 | 1055 | 1056 |

输入文本

1057 |
1058 | 1060 |
1061 | 0/5000 1062 |
1063 |
1064 | 1065 |
1066 |
1067 | 1068 | 1072 |
1073 | 1074 |
1075 | 1076 | 1080 |
1081 |
1082 | 1083 | 1084 |
1085 |
1086 |
1087 | 1088 | (调节语音的快慢程度) 1089 |
1090 |
1091 | 1093 | 0% 1094 |
1095 |
1096 | 1097 |
1098 |
1099 | 1100 | (调节语音的高低音) 1101 |
1102 |
1103 | 1105 | 0% 1106 |
1107 |
1108 |
1109 | 1110 |
1111 | 1120 |
1121 |
1122 | 1123 | 1170 |
1171 | 1172 |
1173 |

© 2025 TTS服务 | API文档

1174 |
1175 |
1176 |
1177 | 1178 | 1179 |
1180 | 1181 | 1312 | 1313 | -------------------------------------------------------------------------------- /workers/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wstrans", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "deploy": "wrangler deploy", 7 | "dev": "wrangler dev", 8 | "start": "wrangler dev" 9 | }, 10 | "devDependencies": { 11 | "wrangler": "^4.6.0" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /workers/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "wstrans" 2 | main = "src/index.js" 3 | compatibility_date = "2024-04-15" 4 | workers_dev = true 5 | compatibility_flags = [ "nodejs_compat" ] 6 | 7 | 8 | [vars] 9 | API_KEY = "zuoban" --------------------------------------------------------------------------------