From 5b970f49e1eccb1d9c6826ff22458aa32c10fe5f Mon Sep 17 00:00:00 2001 From: "3371392206@qq.com" <3371392206@qq.com> Date: Sat, 15 Aug 2026 13:30:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E5=8F=AF=E8=A7=82=E6=B5=8B=E6=80=A7=E6=9E=B6=E6=9E=84=E4=B8=8E?= =?UTF-8?q?=E7=81=AB=E5=B1=B1v3=E9=80=82=E9=85=8D=E5=99=A8=E9=87=8D?= =?UTF-8?q?=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构整体架构: 1. 新增telemetry包实现零依赖的Prometheus指标系统 2. 新增metrics包集中管理业务埋点指标 3. 重构火山v3适配器,拆分client/request/response等模块 4. 替换旧的service/stats统计系统为标准指标埋点 5. 新增/metrics观测端点与完整仪表盘支持 功能更新: - 实现基于IP的限流与并发限制,添加指标埋点 - 重构TTS控制器,支持多格式输出与完整错误分类 - 更新.env.example配置示例,新增多项可选参数 - 替换旧的volcano适配器实现,支持完整的v3 API特性 - 清理冗余代码,移除service/stats与旧adapter实现 --- .env.example | 42 ++- README.md | 54 +++- adapter/volcano/audio.go | 74 +++++ adapter/volcano/client.go | 44 +++ adapter/volcano/errors.go | 27 ++ adapter/volcano/options.go | 63 ++++ adapter/volcano/request.go | 113 +++++++ adapter/volcano/response.go | 141 +++++++++ adapter/volcano/synthesis.go | 172 +++++++++++ adapter/volcano/volcano.go | 206 ------------- controller/tts.go | 150 +++++++--- dto/health.go | 20 +- dto/tts.go | 33 +- health.html | 397 ++++--------------------- main.go | 13 +- metrics/metrics.go | 159 ++++++++++ middleware/ratelimit.go | 8 +- middleware/ratelimit_instrumented.go | 47 +++ middleware/ratelimit_middleware.go.tmp | 32 ++ router/router.go | 11 +- service/stats.go | 124 -------- setting/config.go | 208 ++++++++----- telemetry/counter.go | 90 ++++++ telemetry/format.go | 88 ++++++ telemetry/gauge.go | 96 ++++++ telemetry/histogram.go | 114 +++++++ telemetry/labels.go | 48 +++ telemetry/meter.go | 51 ++++ telemetry/noop.go | 20 ++ telemetry/registry.go | 66 ++++ 30 files changed, 1875 insertions(+), 836 deletions(-) create mode 100644 adapter/volcano/audio.go create mode 100644 adapter/volcano/client.go create mode 100644 adapter/volcano/errors.go create mode 100644 adapter/volcano/options.go create mode 100644 adapter/volcano/request.go create mode 100644 adapter/volcano/response.go create mode 100644 adapter/volcano/synthesis.go delete mode 100644 adapter/volcano/volcano.go create mode 100644 metrics/metrics.go create mode 100644 middleware/ratelimit_instrumented.go create mode 100644 middleware/ratelimit_middleware.go.tmp delete mode 100644 service/stats.go create mode 100644 telemetry/counter.go create mode 100644 telemetry/format.go create mode 100644 telemetry/gauge.go create mode 100644 telemetry/histogram.go create mode 100644 telemetry/labels.go create mode 100644 telemetry/meter.go create mode 100644 telemetry/noop.go create mode 100644 telemetry/registry.go diff --git a/.env.example b/.env.example index 52ce327..f0096f8 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# ByteDance TTS v3 API 配置示例 +# 字节火山引擎 TTS v3 API 配置示例 # 将此文件复制为 .env 并填入实际配置 # ========================================== @@ -8,31 +8,51 @@ # 火山引擎新版控制台获取的 API Key BYTEDANCE_TTS_API_KEY=your_api_key_here -# 资源信息ID(决定使用1.0还是2.0模型) -BYTEDANCE_TTS_RESOURCE_ID=seed-tts-1.0 +# 资源信息ID(决定使用1.0还是2.0模型) +# 复刻 2.0 音色(seed-icl-2.0 / 复刻 1.0 音色(seed-icl-1.0) +BYTEDANCE_TTS_RESOURCE_ID=seed-icl-2.0 -# 发音人(音色)ID +# 发音人(音色)ID BYTEDANCE_TTS_SPEAKER=your_speaker_id_here # ========================================== # 可选的环境变量 # ========================================== -# 请求超时时间,默认30秒 +# 单次合成超时,默认30s BYTEDANCE_TTS_TIMEOUT=30s -# 音频格式:mp3/ogg_opus/pcm/wav(默认mp3) -# 注意:流式场景下wav会多次返回header,内部自动用pcm请求再封装header +# 上游实际请求的音频格式:mp3 / pcm / ogg_opus +# 客户端要求 wav 时,内部自动转 pcm 上游 + 本地拼 WAV 头 BYTEDANCE_TTS_FORMAT=mp3 -# 音频采样率:8000/16000/22050/24000/32000/44100/48000(默认24000) +# 上游采样率:8000/16000/22050/24000/32000/44100/48000 BYTEDANCE_TTS_SAMPLE_RATE=24000 -# OpenAI兼容接口的API密钥(可选) +# MP3 比特率(可选),仅 MP3 生效 +# BYTEDANCE_TTS_BIT_RATE=128000 + +# 复刻 2.0 子模型(可选),留空则使用控制台默认值 +# seed-tts-2.0-standard:标准版,延时更优 +# seed-tts-2.0-expressive:表现力增强版,支持 QA / Cot +# BYTEDANCE_TTS_MODEL=seed-tts-2.0-standard + +# 复刻 2.0 模型类型(可选,推荐显式指定) +# 4 = ICL V2,5 = ICL V3 +# BYTEDANCE_TTS_MODEL_TYPE=4 + +# 非中文/英文合成时指定语种(可选) +# zh-cn / en / ja / es-mx / id / pt-br / ko +# BYTEDANCE_TTS_EXPLICIT_LANGUAGE=zh-cn + +# 复刻 2.0 启用字级时间戳(可选) +# BYTEDANCE_TTS_ENABLE_SUBTITLE=false + +# OpenAI兼容接口的API密钥(可选,多个用逗号分隔) OPENAI_TTS_API_KEY=your_openai_compatible_key_here -# CORS 跨域白名单(逗号分隔,开发环境可设 *) +# CORS 跨域白名单(逗号分隔;开发环境可设 *;空则拒绝所有跨域) # ALLOWED_ORIGINS=https://example.com,https://app.example.com -# 服务监听端口,默认8080 +# 服务监听端口,默认8080 PORT=8080 diff --git a/README.md b/README.md index 57fa4f5..7e357b7 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 字节跳动火山引擎TTS v3 API 转 OpenAI 兼容接口 +# 字节跳动火山引擎TTS v3 API 转 OpenAI 兼容接口 ## 项目简介 @@ -307,6 +307,58 @@ POST /v1/audio/speech 1.2.3.4:56789 200 245ms POST /v1/audio/speech 1.2.3.4:56789 400 1ms ``` + +## 观测 / Metrics + +服务内置 Prometheus 文本格式的 `/metrics` 端点,**不鉴权**(与 `/health` 一致), +可直接被 Prometheus 抓取或浏览器查看。Go 进程内埋点,零外部依赖,实现位于 `telemetry/` 与 `metrics/` 包。 + +### 主要指标 + +| 指标名 | 类型 | 标签 | 说明 | +|---|---|---|---| +| `tts_request_total` | counter | status, format, speaker, model | /v1/audio/speech 请求数 | +| `tts_request_duration_seconds` | histogram | status, format | 端到端延迟 | +| `tts_upstream_total` | counter | status, format, model, speaker | 上游调用数 | +| `tts_upstream_duration_seconds` | histogram | status, format | 上游调用耗时 | +| `tts_upstream_first_byte_seconds` | histogram | format | TTFB | +| `tts_upstream_chunks_total` | counter | format | 收到的音频 chunk 数 | +| `tts_upstream_audio_bytes_total` | counter | format | 实际返回字节数 | +| `tts_upstream_errors_total` | counter | code | 上游错误(code 聚合到 transport/client/server/upstream) | +| `tts_usage_text_words_total` | counter | model | 上游计费字符数 | +| `tts_concurrency_active` | gauge | | 当前在飞请求数 | +| `tts_concurrency_rejected_total` | counter | | 并发上限拒绝数 | +| `tts_ratelimit_rejected_total` | counter | | 速率限制拒绝数 | +| `tts_auth_failed_total` | counter | | API Key 鉴权失败数 | + +### Prometheus 抓取示例 + +```yaml +scrape_configs: + - job_name: tts-api + static_configs: + - targets: ['localhost:8080'] +``` + +### 仪表盘 + +`/dashboard` 仍展示服务状态 + 内存 + 配置信息,并内嵌 `/metrics` 的预览; +Grafana 等工具可直接基于上面指标做面板。 + +## 架构 + +| 包 | 职责 | +|---|---| +| `main.go` | 启动入口,信号处理 | +| `telemetry/` | Counter / Gauge / Histogram + Prometheus 文本导出(零依赖) | +| `metrics/` | TTS 业务指标注册,火山适配器埋点适配 | +| `adapter/volcano/` | 火山 v3 HTTP Chunked 客户端(client/request/response/audio/errors/synthesis) | +| `controller/` | /v1/audio/speech、/health 处理器 | +| `middleware/` | SecurityHeaders、CORS、鉴权、限流、并发、日志、客户端 IP 提取 | +| `setting/` | 单一环境变量入口 + 启动汇总 | +| `common/`、`dto/` | 常量、请求/响应类型 | +| `router/` | 路由注册 | + ## 部署建议 ### Linux Systemd 服务 diff --git a/adapter/volcano/audio.go b/adapter/volcano/audio.go new file mode 100644 index 0000000..29528c3 --- /dev/null +++ b/adapter/volcano/audio.go @@ -0,0 +1,74 @@ +package volcano + +import ( + "encoding/binary" + "fmt" +) + +// 标准 PCM WAV 头(44 字节)。 +// 文档 3.3 节:流式场景不推荐 wav(会多次返回 wav header), +// 本项目策略:上游走 pcm,本地拼一次标准头,避免拼接过个 header。 +type wavHeader struct { + // RIFF chunk descriptor + ChunkID [4]byte // "RIFF" + ChunkSize uint32 // 36 + SubChunk2Size + Format [4]byte // "WAVE" + // fmt sub-chunk + Subchunk1ID [4]byte // "fmt " + Subchunk1Size uint32 // 16 for PCM + AudioFormat uint16 // 1 = PCM + NumChannels uint16 + SampleRate uint32 + ByteRate uint32 + BlockAlign uint16 + BitsPerSample uint16 + // data sub-chunk + Subchunk2ID [4]byte // "data" + Subchunk2Size uint32 +} + +// WrapWAVHeader 把 PCM 原始字节封装成完整的 WAV 字节流。 +// sampleRate 决定 WAV 头里的采样率字段;pcm 视为 16-bit 单声道 little-endian。 +func WrapWAVHeader(pcm []byte, sampleRate int) ([]byte, error) { + if sampleRate <= 0 { + return nil, fmt.Errorf("invalid sample rate %d", sampleRate) + } + const channels uint16 = 1 + const bitsPerSample uint16 = 16 + blockAlign := channels * bitsPerSample / 8 + byteRate := uint32(sampleRate) * uint32(blockAlign) + dataSize := uint32(len(pcm)) + + hdr := wavHeader{ + ChunkID: [4]byte{'R', 'I', 'F', 'F'}, + ChunkSize: 36 + dataSize, + Format: [4]byte{'W', 'A', 'V', 'E'}, + Subchunk1ID: [4]byte{'f', 'm', 't', ' '}, + Subchunk1Size: 16, + AudioFormat: 1, + NumChannels: channels, + SampleRate: uint32(sampleRate), + ByteRate: byteRate, + BlockAlign: blockAlign, + BitsPerSample: bitsPerSample, + Subchunk2ID: [4]byte{'d', 'a', 't', 'a'}, + Subchunk2Size: dataSize, + } + + out := make([]byte, 0, 44+len(pcm)) + out = append(out, hdr.ChunkID[:]...) + out = binary.LittleEndian.AppendUint32(out, hdr.ChunkSize) + out = append(out, hdr.Format[:]...) + out = append(out, hdr.Subchunk1ID[:]...) + out = binary.LittleEndian.AppendUint32(out, hdr.Subchunk1Size) + out = binary.LittleEndian.AppendUint16(out, hdr.AudioFormat) + out = binary.LittleEndian.AppendUint16(out, hdr.NumChannels) + out = binary.LittleEndian.AppendUint32(out, hdr.SampleRate) + out = binary.LittleEndian.AppendUint32(out, hdr.ByteRate) + out = binary.LittleEndian.AppendUint16(out, hdr.BlockAlign) + out = binary.LittleEndian.AppendUint16(out, hdr.BitsPerSample) + out = append(out, hdr.Subchunk2ID[:]...) + out = binary.LittleEndian.AppendUint32(out, hdr.Subchunk2Size) + out = append(out, pcm...) + return out, nil +} diff --git a/adapter/volcano/client.go b/adapter/volcano/client.go new file mode 100644 index 0000000..ef128ae --- /dev/null +++ b/adapter/volcano/client.go @@ -0,0 +1,44 @@ +package volcano + +import ( + "bytes" + "context" + "fmt" + "net/http" + "time" +) + +// HTTPClient 持有共享的 http.Client 以便复用连接(v3 keep-alive 1 分钟)。 +type HTTPClient struct { + client *http.Client +} + +// NewHTTPClient 构造默认配置的 HTTPClient。 +func NewHTTPClient() *HTTPClient { + return &HTTPClient{ + client: &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 20, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + }, + }, + } +} + +// PostStream 发送一次流式请求,返回带上下文的 *http.Response。 +// 调用方负责关闭 resp.Body。 +func (h *HTTPClient) PostStream(ctx context.Context, url string, headers map[string]string, body []byte) (*http.Response, error) { + if ctx == nil { + ctx = context.Background() + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + for k, v := range headers { + req.Header.Set(k, v) + } + return h.client.Do(req) +} diff --git a/adapter/volcano/errors.go b/adapter/volcano/errors.go new file mode 100644 index 0000000..1506d9e --- /dev/null +++ b/adapter/volcano/errors.go @@ -0,0 +1,27 @@ +package volcano + +import "fmt" + +// UpstreamError 表示火山 v3 返回的 业务错误(code != 0 且 != 20000000)或传输错误。 +// 包含上游错误码,便于 telemetry 把它作为 label。 +type UpstreamError struct { + Code int + Message string + Stage string // "request"/"stream"/"http" - 出错阶段 + Wrapped error +} + +func (e *UpstreamError) Error() string { + if e.Wrapped != nil { + return fmt.Sprintf("volcano %s: code=%d %s: %v", e.Stage, e.Code, e.Message, e.Wrapped) + } + return fmt.Sprintf("volcano %s: code=%d %s", e.Stage, e.Code, e.Message) +} + +func (e *UpstreamError) Unwrap() error { return e.Wrapped } + +// IsAuth 当上游返回认证/权限类错误时返回 true。 +func (e *UpstreamError) IsAuth() bool { + return e.Code == 45000000 || e.Code == 55000000 || + e.Code == 401 || e.Code == 403 +} diff --git a/adapter/volcano/options.go b/adapter/volcano/options.go new file mode 100644 index 0000000..444ecd2 --- /dev/null +++ b/adapter/volcano/options.go @@ -0,0 +1,63 @@ +package volcano + +// Options 是火山 v3 TTS 适配器的完整调用参数集合。 +// 由 setting 包从环境变量构造,controller 直接透传,不做 OpenAI 侧映射。 +// +// 字段顺序与文档 3.x 节一致,便于对照。 +type Options struct { + // --- 鉴权 / 路由 --- + APIKey string // X-Api-Key + ResourceID string // X-Api-Resource-Id,决定模型版本与计费,如 seed-icl-2.0 + + // --- req_params 核心字段 --- + Text string + Speaker string + Model string // 可空,仅复刻 2.0 生效;env 默认 seed-tts-2.0-standard + UID string // user.uid,默认 "uid" + + // --- audio_params --- + Format string // 上游实际请求的 format:mp3 / pcm / ogg_opus + SampleRate int // 8000/16000/22050/24000/32000/44100/48000 + BitRate int // 可选,仅 MP3 生效 + SpeechRate int // [-50, 100] + LoudnessRate int // [-50, 100] + EnableSubtitle bool // 复刻 2.0 生效,返回 TTSSubtitle + EnableTimestamp bool // 复刻 1.0 生效,内嵌字级时间戳 + + // --- additions(扩展参数,JSON 字符串承载)--- + // 文档明确 additions 在请求体里必须是 string,内容是 JSON。 + // 这里直接存结构体,序列化时由 MarshalJSON 输出为 string。 + Additions *Additions +} + +// Additions 对应文档 3.4 节的扩展参数。 +// 注意:在请求体里 additions 是 JSON 字符串,所以 MarshalJSON 序列化为 string。 +type Additions struct { + ModelType *int `json:"model_type,omitempty"` // 复刻 2.0 推荐显式指定,4=ICL V2、5=ICL V3 + ContextTexts []string `json:"context_texts,omitempty"` // 语音指令 + UseTagParser *bool `json:"use_tag_parser,omitempty"` // 复刻 2.0 expressive 启用语音标签 Cot + ExplicitLanguage string `json:"explicit_language,omitempty"` // 明确语种 + ContextLanguage string `json:"context_language,omitempty"` // 参考语种 + SilenceDuration *int `json:"silence_duration,omitempty"` // 0~30000ms + EnableLanguageDetector *bool `json:"enable_language_detector,omitempty"` // 自动识别语种 + DisableMarkdownFilter *bool `json:"disable_markdown_filter,omitempty"` // 是否解析 markdown + DisableEmojiFilter *bool `json:"disable_emoji_filter,omitempty"` // 是否过滤 emoji + MaxLengthFilterParenthesis *int `json:"max_length_to_filter_parenthesis,omitempty"` + UnsupportedCharRatio *float64 `json:"unsupported_char_ratio_thresh,omitempty"` + AIGCWatermark *bool `json:"aigc_watermark,omitempty"` + AIGCMetadata any `json:"aigc_metadata,omitempty"` + CacheConfig any `json:"cache_config,omitempty"` + PostProcess any `json:"post_process,omitempty"` +} + +// IsZero 报告 Additions 是否为空(没有任何字段设置),用于在序列化前跳过 additions。 +func (a *Additions) IsZero() bool { + if a == nil { + return true + } + return a.ModelType == nil && a.ContextTexts == nil && a.UseTagParser == nil && + a.ExplicitLanguage == "" && a.ContextLanguage == "" && a.SilenceDuration == nil && + a.EnableLanguageDetector == nil && a.DisableMarkdownFilter == nil && a.DisableEmojiFilter == nil && + a.MaxLengthFilterParenthesis == nil && a.UnsupportedCharRatio == nil && + a.AIGCWatermark == nil && a.AIGCMetadata == nil && a.CacheConfig == nil && a.PostProcess == nil +} diff --git a/adapter/volcano/request.go b/adapter/volcano/request.go new file mode 100644 index 0000000..a244263 --- /dev/null +++ b/adapter/volcano/request.go @@ -0,0 +1,113 @@ +package volcano + +import ( + "encoding/json" + "fmt" +) + +// requestBody 是真正发到上游 v3 端点的 JSON 顶层结构。 +type requestBody struct { + User ttsUser `json:"user"` + Namespace string `json:"namespace"` + ReqParams ttsReqParams `json:"req_params"` +} + +type ttsUser struct { + UID string `json:"uid"` +} + +type ttsReqParams struct { + Text string `json:"text"` + Speaker string `json:"speaker"` + Model string `json:"model,omitempty"` + AudioParams ttsAudioParams `json:"audio_params"` + Additions string `json:"additions,omitempty"` // 注意:字符串 +} + +type ttsAudioParams struct { + Format string `json:"format"` + SampleRate int `json:"sample_rate"` + BitRate int `json:"bit_rate,omitempty"` + SpeechRate int `json:"speech_rate"` + LoudnessRate int `json:"loudness_rate,omitempty"` + EnableSubtitle bool `json:"enable_subtitle,omitempty"` + EnableTimestamp bool `json:"enable_timestamp,omitempty"` +} + +// buildRequest 把 Options 序列化为上游请求体 JSON。 +func buildRequest(opts Options) ([]byte, error) { + if opts.Text == "" { + return nil, fmt.Errorf("volcano: text is required") + } + if opts.Speaker == "" { + return nil, fmt.Errorf("volcano: speaker is required") + } + if opts.ResourceID == "" { + return nil, fmt.Errorf("volcano: resource id is required") + } + if opts.APIKey == "" { + return nil, fmt.Errorf("volcano: api key is required") + } + + body := requestBody{ + User: ttsUser{UID: opts.UID}, + Namespace: "BidirectionalTTS", + ReqParams: ttsReqParams{ + Text: opts.Text, + Speaker: opts.Speaker, + Model: opts.Model, + AudioParams: ttsAudioParams{ + Format: opts.Format, + SampleRate: opts.SampleRate, + BitRate: opts.BitRate, + SpeechRate: opts.SpeechRate, + LoudnessRate: opts.LoudnessRate, + EnableSubtitle: opts.EnableSubtitle, + EnableTimestamp: opts.EnableTimestamp, + }, + }, + } + + if opts.Additions != nil && !opts.Additions.IsZero() { + // 文档明确 additions 字段为 JSON 字符串。 + raw, err := json.Marshal(opts.Additions) + if err != nil { + return nil, fmt.Errorf("marshal additions: %w", err) + } + body.ReqParams.Additions = string(raw) + } + + raw, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + return raw, nil +} + +// convertSpeedToSpeechRate 把 OpenAI 风格的 speed(倍率)转换为 speech_rate(百分比)。 +// speech_rate 范围 [-50, 100],对应 0.5x ~ 2.0x。 +func convertSpeedToSpeechRate(speed float64) int { + if speed <= 0 { + speed = 1.0 + } + rate := int((speed - 1.0) * 100) + if rate < -50 { + rate = -50 + } + if rate > 100 { + rate = 100 + } + return rate +} + +// resolveUpstreamFormat 决定上游实际请求的 format。 +// - 客户端要求 wav -> 上游走 pcm,我们本地拼 header +// - 其他 -> 直接用 clientFormat +// +// sampleRate 在 wav 走 pcm 的情况下也按原样传给上游(影响 PCM 的实际采样率)。 +func resolveUpstreamFormat(clientFormat string) string { + if clientFormat == "wav" { + return "pcm" + } + return clientFormat +} diff --git a/adapter/volcano/response.go b/adapter/volcano/response.go new file mode 100644 index 0000000..a75418f --- /dev/null +++ b/adapter/volcano/response.go @@ -0,0 +1,141 @@ +package volcano + +import ( + "bufio" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "time" + + "github.com/volcano-tts/tts-api/dto" +) + +// ParsedStream 是一次流式响应的累计结果。 +type ParsedStream struct { + AudioData []byte + Chunks int + TextWords int + FirstChunk time.Duration // 从请求发起到收到第一个 sentence chunk 的耗时 + HasUsage bool + Subtitles []dto.SubtitleEntry +} + +// ParseStream 读取 v3 chunked NDJSON 响应,按文档 5.1 节的 event 取值分类处理。 +// +// 关键修复(对比原实现):只有 event == "sentence" 才是音频帧; +// TTSSubtitle 单独收集,不会污染音频字节流。 +func ParseStream(body io.Reader, started time.Time) (*ParsedStream, error) { + out := &ParsedStream{} + scanner := bufio.NewScanner(body) + scanner.Buffer(make([]byte, 1024*1024), 8*1024*1024) + + gotFirstChunk := false + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var resp dto.V3TTSResponse + if err := json.Unmarshal(line, &resp); err != nil { + log.Printf("volcano: 解析响应行失败: %v, line=%q", err, truncateForLog(line, 200)) + continue + } + + if resp.Code != 0 && resp.Code != 20000000 { + return nil, &UpstreamError{ + Code: resp.Code, + Message: resp.Message, + Stage: "stream", + } + } + + if resp.Code == 20000000 { + if resp.Usage != nil { + out.TextWords = resp.Usage.TextWords + out.HasUsage = true + log.Printf("TTS 合成结束, usage: text_words=%d", out.TextWords) + } + for scanner.Scan() { + } + break + } + + // 事件分发:显式匹配已知事件,绝不把未知事件当作音频。 + switch resp.Event { + case "TTSSentenceStart": + log.Printf("Sentence start: sequence=%d, sentence=%s", resp.Sequence, resp.Sentence) + case "TTSSentenceEnd": + log.Printf("Sentence end: sequence=%d", resp.Sequence) + case "TTSSubtitle": + if resp.Data != "" { + out.Subtitles = append(out.Subtitles, dto.SubtitleEntry{ + Text: resp.Sentence, + Sequence: resp.Sequence, + }) + } + case "sentence": + if resp.Data == "" { + continue + } + chunk, err := base64.StdEncoding.DecodeString(resp.Data) + if err != nil { + return nil, &UpstreamError{ + Code: resp.Code, + Message: fmt.Sprintf("decode audio chunk: %v", err), + Stage: "stream", + Wrapped: err, + } + } + out.AudioData = append(out.AudioData, chunk...) + out.Chunks++ + if !gotFirstChunk { + out.FirstChunk = time.Since(started) + gotFirstChunk = true + } + case "": + // 传输帧,跳过 + default: + log.Printf("volcano: 忽略未识别事件 event=%q sequence=%d", resp.Event, resp.Sequence) + } + } + + if err := scanner.Err(); err != nil { + return nil, &UpstreamError{ + Code: 0, + Message: fmt.Sprintf("read stream: %v", err), + Stage: "stream", + Wrapped: err, + } + } + + if len(out.AudioData) == 0 { + return nil, &UpstreamError{ + Code: 0, + Message: "no audio data received from TTS service", + Stage: "stream", + } + } + + return out, nil +} + +// ReadErrorBody 把非 200 响应的 body 读出来用于日志。 +func ReadErrorBody(body io.Reader) string { + const max = 2048 + buf := make([]byte, max) + n, err := io.ReadFull(body, buf) + if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) { + return fmt.Sprintf("read body fail: %v", err) + } + return string(buf[:n]) +} + +func truncateForLog(b []byte, max int) string { + if len(b) > max { + return string(b[:max]) + fmt.Sprintf("...(truncated, total %d bytes)", len(b)) + } + return string(b) +} diff --git a/adapter/volcano/synthesis.go b/adapter/volcano/synthesis.go new file mode 100644 index 0000000..ecca9a4 --- /dev/null +++ b/adapter/volcano/synthesis.go @@ -0,0 +1,172 @@ +package volcano + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "log" + "time" + + "github.com/volcano-tts/tts-api/dto" +) + +// MetricsRecorder 是适配器向上报告埋点的接口。 +// 适配器本身不依赖 telemetry 包,controller 在 main 启动时把 Meter 适配成实现; +// 这样测试可以注入 mock,生产可以无侵入替换成 OTel。 +type MetricsRecorder interface { + UpstreamStarted(speaker, model, format string) + UpstreamFinished(speaker, model, format, status string, duration, ttfb time.Duration, chunks, audioBytes, errCode int) + UpstreamUsage(model string, textWords int) +} + +// nopMetrics 是 MetricsRecorder 的 no-op 默认值。 +type nopMetrics struct{} + +func (nopMetrics) UpstreamStarted(string, string, string) {} +func (nopMetrics) UpstreamFinished(string, string, string, string, time.Duration, time.Duration, int, int, int) { +} +func (nopMetrics) UpstreamUsage(string, int) {} + +// Synthesis 调用火山 v3 一次,返回组装好的结果。 +func Synthesis( + ctx context.Context, + client *HTTPClient, + opts Options, + text string, + clientFormat string, + speed float64, + mtr MetricsRecorder, +) (*dto.SynthesisResult, error) { + if mtr == nil { + mtr = nopMetrics{} + } + opts.Text = text + opts.SpeechRate = convertSpeedToSpeechRate(speed) + + reqID := newRequestID() + + upstreamFormat := resolveUpstreamFormat(clientFormat) + opts.Format = upstreamFormat + if upstreamFormat != "pcm" && upstreamFormat != "mp3" && upstreamFormat != "ogg_opus" { + opts.Format = "mp3" + } + + started := time.Now() + mtr.UpstreamStarted(opts.Speaker, opts.Model, opts.Format) + + body, err := buildRequest(opts) + if err != nil { + mtr.UpstreamFinished(opts.Speaker, opts.Model, opts.Format, "request_error", time.Since(started), 0, 0, 0, 0) + return nil, &UpstreamError{Code: 0, Message: err.Error(), Stage: "request", Wrapped: err} + } + + headers := map[string]string{ + "Content-Type": "application/json", + "Connection": "keep-alive", + "X-Api-Resource-Id": opts.ResourceID, + "X-Api-Request-Id": reqID, + "X-Api-Key": opts.APIKey, + "X-Control-Require-Usage-Tokens-Return": "*", + } + + log.Printf("TTS upstream: resource_id=%s speaker=%s model=%q format=%s sample_rate=%d speech_rate=%d additions=%q", + opts.ResourceID, opts.Speaker, opts.Model, opts.Format, opts.SampleRate, opts.SpeechRate, extractAdditionsForLog(body)) + + resp, err := client.PostStream(ctx, "https://openspeech.bytedance.com/api/v3/tts/unidirectional", headers, body) + if err != nil { + mtr.UpstreamFinished(opts.Speaker, opts.Model, opts.Format, "transport_error", time.Since(started), 0, 0, 0, 0) + return nil, &UpstreamError{Code: 0, Message: err.Error(), Stage: "request", Wrapped: err} + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + rawBody := ReadErrorBody(resp.Body) + mtr.UpstreamFinished(opts.Speaker, opts.Model, opts.Format, fmt.Sprintf("http_%d", resp.StatusCode), time.Since(started), 0, 0, 0, resp.StatusCode) + return nil, &UpstreamError{ + Code: resp.StatusCode, + Message: fmt.Sprintf("upstream http %d: %s", resp.StatusCode, rawBody), + Stage: "http", + } + } + + parsed, err := ParseStream(resp.Body, started) + if err != nil { + ue, _ := err.(*UpstreamError) + code := 0 + if ue != nil { + code = ue.Code + } + mtr.UpstreamFinished(opts.Speaker, opts.Model, opts.Format, "stream_error", time.Since(started), 0, 0, 0, code) + return nil, err + } + + duration := time.Since(started) + + finalData := parsed.AudioData + finalFormat := clientFormat + sampleRate := opts.SampleRate + if clientFormat == "wav" { + wav, wrapErr := WrapWAVHeader(parsed.AudioData, opts.SampleRate) + if wrapErr != nil { + mtr.UpstreamFinished(opts.Speaker, opts.Model, opts.Format, "wrap_error", duration, parsed.FirstChunk, parsed.Chunks, len(parsed.AudioData), 0) + return nil, &UpstreamError{Code: 0, Message: wrapErr.Error(), Stage: "wrap", Wrapped: wrapErr} + } + finalData = wav + } + + if parsed.HasUsage { + mtr.UpstreamUsage(opts.Model, parsed.TextWords) + } + mtr.UpstreamFinished(opts.Speaker, opts.Model, opts.Format, "ok", duration, parsed.FirstChunk, parsed.Chunks, len(finalData), 0) + + return &dto.SynthesisResult{ + AudioData: finalData, + Format: finalFormat, + SampleRate: sampleRate, + ReqID: reqID, + TextWords: parsed.TextWords, + Chunks: parsed.Chunks, + AudioBytes: len(finalData), + TTFB: parsed.FirstChunk, + Duration: duration, + }, nil +} + +// newRequestID 16 字节随机 ID(hex 编码),无外部依赖。 +func newRequestID() string { + var b [16]byte + _, _ = rand.Read(b[:]) + return hex.EncodeToString(b[:]) +} + +// extractAdditionsForLog 从已编码的请求体里取 additions 字段值,便于日志展示。 +func extractAdditionsForLog(body []byte) string { + const key = `"additions":"` + idx := bytesIndex(body, key) + if idx < 0 { + return "" + } + rest := body[idx+len(key):] + end := bytesIndex(rest, []byte{'"'}) + if end < 0 { + return "" + } + return string(rest[:end]) +} + +func bytesIndex(haystack []byte, needle string) int { + if len(needle) == 0 { + return 0 + } +outer: + for i := 0; i+len(needle) <= len(haystack); i++ { + for j := 0; j < len(needle); j++ { + if haystack[i+j] != needle[j] { + continue outer + } + } + return i + } + return -1 +} diff --git a/adapter/volcano/volcano.go b/adapter/volcano/volcano.go deleted file mode 100644 index 3a4fc30..0000000 --- a/adapter/volcano/volcano.go +++ /dev/null @@ -1,206 +0,0 @@ -package volcano - -import ( - "bufio" - "bytes" - "context" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "time" - - "github.com/google/uuid" - "github.com/volcano-tts/tts-api/dto" -) - -// 火山引擎 TTS v3 HTTP 单向流式 API 客户端。 -// 官方文档:https://www.volcengine.com/docs/6561/1598757 -// 官方 Go 示例:请求体仅含 req_params;严格按单文件参考实现的请求结构。 - -type HTTPClient struct { - client *http.Client -} - -func NewHTTPClient() *HTTPClient { - return &HTTPClient{ - client: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 20, - IdleConnTimeout: 90 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - }, - }, - } -} - -func (h *HTTPClient) PostStream(url string, headers map[string]string, body []byte, timeout time.Duration) (*http.Response, error) { - req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(body)) - if err != nil { - return nil, err - } - for key, value := range headers { - req.Header.Set(key, value) - } - - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - req = req.WithContext(ctx) - return h.client.Do(req) -} - -// --- 请求体结构(对应火山 v3 API 请求 JSON) --- - -type ttsRequest struct { - User ttsUser `json:"user"` - Namespace string `json:"namespace"` - ReqParams ttsReqParams `json:"req_params"` -} - -type ttsUser struct { - UID string `json:"uid"` -} - -type ttsReqParams struct { - Text string `json:"text"` - Speaker string `json:"speaker"` - AudioParams ttsAudioParams `json:"audio_params"` -} - -type ttsAudioParams struct { - Format string `json:"format"` - SampleRate int `json:"sample_rate"` - SpeechRate int `json:"speech_rate"` -} - -// convertSpeedToSpeechRate 把 OpenAI 风格的 speed(倍率)转成火山 v3 的 speech_rate(百分比)。 -// 文档规定 speech_rate 范围 [-50, 100],对应 0.5x ~ 2.0x 倍速。 -// 输入超出范围会被截断到边界值。 -func convertSpeedToSpeechRate(speed float64) int { - rate := int((speed - 1.0) * 100) - if rate < -50 { - rate = -50 - } - if rate > 100 { - rate = 100 - } - return rate -} -func Synthesis(config *dto.ByteDanceTTSConfig, httpClient *HTTPClient, text string, speed float64) (*dto.SynthesisResult, error) { - reqID := uuid.NewString() - speechRate := convertSpeedToSpeechRate(speed) - - // 构造请求体:严格按 v3 API 文档 JSON 结构 - req := ttsRequest{ - User: ttsUser{UID: "uid"}, - Namespace: "BidirectionalTTS", - ReqParams: ttsReqParams{ - Text: text, - Speaker: config.Speaker, - AudioParams: ttsAudioParams{ - Format: "wav", - SampleRate: 24000, - SpeechRate: speechRate, - }, - }, - } - - body, err := json.Marshal(req) - if err != nil { - return nil, fmt.Errorf("marshal TTS request: %w", err) - } - // 诊断日志:记录实际发到上游的请求体(去 model/speaker/resource 关键字段) - log.Printf("TTS upstream request: X-Api-Resource-Id=%s speaker=%s namespace=BidirectionalTTS body=%s", - config.ResourceId, config.Speaker, string(body)) - - // 鉴权 header 按 v3 新版控制台方式(Connection 由 Go http 默认 keep-alive) - headers := map[string]string{ - "Content-Type": "application/json", - "Connection": "keep-alive", - "X-Api-Resource-Id": config.ResourceId, - "X-Api-Request-Id": reqID, - "X-Api-Key": config.ApiKey, - } - - resp, err := httpClient.PostStream(config.URL, headers, body, config.Timeout) - if err != nil { - return nil, fmt.Errorf("send TTS request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - respBody, readErr := io.ReadAll(resp.Body) - if readErr != nil { - log.Printf("TTS service error: status=%d, read body fail: %v", resp.StatusCode, readErr) - } else { - log.Printf("TTS service error: status=%d, body=%s", resp.StatusCode, string(respBody)) - } - return nil, fmt.Errorf("TTS service error: status %d", resp.StatusCode) - } - - var audioData []byte - scanner := bufio.NewScanner(resp.Body) - // 初始 1MB / 最大 8MB,与示例同量级,留足 TTS 长文本 room - scanner.Buffer(make([]byte, 1024*1024), 8*1024*1024) - - for scanner.Scan() { - line := scanner.Bytes() - if len(line) == 0 { - continue - } - - var v3Resp dto.V3TTSResponse - if err := json.Unmarshal(line, &v3Resp); err != nil { - log.Printf("unmarshal chunk fail: %v, line: %s", err, string(line)) - continue - } - - // code=20000000 表示合成结束 - if v3Resp.Code == 20000000 { - if v3Resp.Usage != nil { - log.Printf("TTS synthesis completed, usage: %+v", v3Resp.Usage) - } - // 跳过后续可能的空行 - for scanner.Scan() { - } - break - } - - // 非零 code 为错误 - if v3Resp.Code != 0 { - log.Printf("TTS service error: code=%d, message=%s, event=%s", v3Resp.Code, v3Resp.Message, v3Resp.Event) - return nil, fmt.Errorf("TTS service error: %s", v3Resp.Message) - } - - // 根据 event 字段分类处理 - switch v3Resp.Event { - case "TTSSentenceStart": - log.Printf("Sentence start: sequence=%d, sentence=%s", v3Resp.Sequence, v3Resp.Sentence) - case "TTSSentenceEnd": - log.Printf("Sentence end: sequence=%d", v3Resp.Sequence) - default: - // 音频数据 chunk:data 字段为 base64 编码的音频片段 - if v3Resp.Data != "" { - chunk, err := base64.StdEncoding.DecodeString(v3Resp.Data) - if err != nil { - return nil, fmt.Errorf("decode audio chunk: %w", err) - } - audioData = append(audioData, chunk...) - } - } - } - - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("read TTS stream: %w", err) - } - - if len(audioData) == 0 { - return nil, fmt.Errorf("no audio data received from TTS service") - } - - return &dto.SynthesisResult{AudioData: audioData, ReqID: reqID, Format: "wav"}, nil -} diff --git a/controller/tts.go b/controller/tts.go index 66e4c54..49a409f 100644 --- a/controller/tts.go +++ b/controller/tts.go @@ -1,29 +1,34 @@ -package controller +package controller import ( + "context" "encoding/json" "fmt" "io" "log" "net/http" + "runtime" "strings" "time" "github.com/volcano-tts/tts-api/adapter/volcano" "github.com/volcano-tts/tts-api/common" "github.com/volcano-tts/tts-api/dto" + "github.com/volcano-tts/tts-api/metrics" "github.com/volcano-tts/tts-api/middleware" - "github.com/volcano-tts/tts-api/service" "github.com/volcano-tts/tts-api/setting" + "github.com/volcano-tts/tts-api/telemetry" ) -var volcanoClient *volcano.HTTPClient +var ( + volcanoClient *volcano.HTTPClient + adapterRec volcano.MetricsRecorder = metrics.AdapterRecorder{} +) func InitController() { volcanoClient = volcano.NewHTTPClient() } -// truncateForLog 用于在日志中安全地展示请求内容(截断避免日志爆炸、控制不可打印字符) func truncateForLog(b []byte, max int) string { if len(b) > max { return string(b[:max]) + fmt.Sprintf("...(truncated, total %d bytes)", len(b)) @@ -31,15 +36,36 @@ func truncateForLog(b []byte, max int) string { return string(b) } +// resolveClientFormat 把 OpenAI 风格的 response_format 映射为最终输出格式; +// 不识别或未指定时回退到 setting.TTSOptions.Format。 +func resolveClientFormat(reqFmt string) string { + switch strings.ToLower(reqFmt) { + case "mp3", "wav", "opus", "pcm", "aac", "flac": + if reqFmt == "opus" { + return "ogg_opus" + } + return strings.ToLower(reqFmt) + } + if reqFmt == "" { + return setting.TTSOptions.Format + } + return setting.TTSOptions.Format +} + +// OpenaiTTSHandler 是 /v1/audio/speech 的入口。 func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) { + start := time.Now() + if r.Method != http.MethodPost { log.Printf("警告: 错误的方法 - 方法=%s 期望=POST 路径=%s 客户端=%s", r.Method, r.URL.Path, middleware.GetClientIP(r)) + metrics.RequestTotal.Inc(telemetry.Labels{"status": "method_not_allowed", "format": "", "speaker": "", "model": ""}) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } if !middleware.ValidateAPIKey(r) { + metrics.AuthFailed.Inc(telemetry.Labels{}) log.Printf("警告: API Key 鉴权失败 - 路径=%s 客户端=%s 远端=%s", r.URL.Path, middleware.GetClientIP(r), r.RemoteAddr) middleware.SendJSONError(w, http.StatusUnauthorized, "Invalid API key provided.", "invalid_request_error", "invalid_api_key") @@ -47,7 +73,7 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) { } if setting.TTSConfigErr != nil { - log.Printf("警告: TTS配置未就绪,拒绝请求 - 错误=%v 路径=%s 客户端=%s", + log.Printf("警告: TTS配置未就绪,拒绝请求 - 错误=%v 路径=%s 客户端=%s", setting.TTSConfigErr, r.URL.Path, middleware.GetClientIP(r)) middleware.SendJSONError(w, http.StatusServiceUnavailable, "TTS service configuration error. Please check environment variables and restart the service.", "configuration_error", "service_unavailable") return @@ -96,7 +122,6 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "Input text is required", http.StatusBadRequest) return } - if len(req.Input) > common.MaxTextLength { log.Printf("警告: input 文本过长 - 路径=%s 客户端=%s 长度=%d 限制=%d", r.URL.Path, middleware.GetClientIP(r), len(req.Input), common.MaxTextLength) @@ -115,27 +140,78 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) { speed = common.MaxSpeed } - ttsStart := time.Now() - result, err := volcano.Synthesis(&setting.TTSConfig, volcanoClient, req.Input, speed) - duration := time.Since(ttsStart) + clientFormat := resolveClientFormat(req.ResponseFormat) + opts := setting.TTSOptions + opts.Text = req.Input + + ctx, cancel := context.WithTimeout(r.Context(), setting.TTSTimeout) + defer cancel() + + result, err := volcano.Synthesis(ctx, volcanoClient, opts, req.Input, clientFormat, speed, adapterRec) + duration := time.Since(start) + + finalLabels := telemetry.Labels{ + "format": clientFormat, + "speaker": opts.Speaker, + "model": opts.Model, + } if err != nil { - service.GlobalStats.AddRequest(false, duration, err.Error()) + finalLabels["status"] = classifyStatus(err) + metrics.RequestTotal.Inc(finalLabels) + metrics.RequestDuration.Observe(duration.Seconds(), telemetry.Labels{"status": finalLabels["status"], "format": clientFormat}) log.Printf("警告: TTS 合成失败 - 路径=%s 客户端=%s 文本长度=%d 耗时=%v 错误=%v", r.URL.Path, middleware.GetClientIP(r), len(req.Input), duration, err) http.Error(w, "TTS synthesis failed", http.StatusInternalServerError) return } - service.GlobalStats.AddRequest(true, duration, "") + finalLabels["status"] = "ok" + metrics.RequestTotal.Inc(finalLabels) + metrics.RequestDuration.Observe(duration.Seconds(), telemetry.Labels{"status": "ok", "format": clientFormat}) - w.Header().Set("Content-Type", "audio/wav") + w.Header().Set("Content-Type", contentTypeFor(result.Format)) w.Header().Set("Content-Length", fmt.Sprintf("%d", len(result.AudioData))) w.Header().Set("X-Request-Id", result.ReqID) w.WriteHeader(http.StatusOK) w.Write(result.AudioData) } +func classifyStatus(err error) string { + if ue, ok := err.(*volcano.UpstreamError); ok { + switch ue.Stage { + case "request": + return "request_error" + case "http": + return fmt.Sprintf("http_%d", ue.Code) + case "stream": + return "upstream_error" + case "wrap": + return "wrap_error" + } + } + return "internal_error" +} + +func contentTypeFor(format string) string { + switch strings.ToLower(format) { + case "wav": + return "audio/wav" + case "mp3": + return "audio/mpeg" + case "ogg_opus", "opus": + return "audio/ogg" + case "pcm": + return "audio/L16" + case "aac": + return "audio/aac" + case "flac": + return "audio/flac" + } + return "application/octet-stream" +} + +// HealthHandler 暴露运行期状态;无鉴权。 func HealthHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -145,55 +221,39 @@ func HealthHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } - totalRequests, successfulRequests, failedRequests, totalResponseTime, recentResponseTimes, lastErrors := service.GlobalStats.GetSnapshot() - - var errorRate float64 - if totalRequests > 0 { - errorRate = float64(failedRequests) / float64(totalRequests) * 100 - } - - var avgResponseTime float64 - if totalRequests > 0 { - avgResponseTime = totalResponseTime.Seconds() * 1000 / float64(totalRequests) - } - - envCheckStatus := setting.CheckEnvironmentVariables() - allEnvVarsSet := envCheckStatus["all_required_vars_set"].(bool) + env := setting.CheckEnvironmentVariables() + allRequired := env["all_required_vars_set"].(bool) status := "ok" - if !allEnvVarsSet { + if !allRequired { status = "configuration_error" } - response := dto.HealthResponse{ + resp := dto.HealthResponse{ Status: status, Service: "ByteDance TTS to OpenAI API Adapter", Version: "2.0.0 (v3 API)", Uptime: fmt.Sprintf("%.0f seconds", time.Since(startTime).Seconds()), StartTime: startTime.Format(time.RFC3339), - Memory: service.GetMemoryInfo(), - APIStats: dto.APIStatsResponse{ - TotalRequests: int(totalRequests), - SuccessfulRequests: successfulRequests, - FailedRequests: failedRequests, - ErrorRatePercent: fmt.Sprintf("%.2f", errorRate), - AvgResponseTimeMs: fmt.Sprintf("%.2f", avgResponseTime), - RecentResponseTimesMs: recentResponseTimes, - }, - Errors: dto.ErrorResponse{ - RecentErrorsCount: len(lastErrors), - }, + Memory: collectMemorySnapshot(), ConfigStatus: dto.ConfigStatusResponse{ - AllRequiredVarsSet: allEnvVarsSet, + AllRequiredVarsSet: allRequired, ConfigError: setting.TTSConfigErr != nil, }, } - - json.NewEncoder(w).Encode(response) + json.NewEncoder(w).Encode(resp) } var startTime time.Time -func SetStartTime(t time.Time) { - startTime = t +func SetStartTime(t time.Time) { startTime = t } + +func collectMemorySnapshot() map[string]interface{} { + var ms runtime.MemStats + runtime.ReadMemStats(&ms) + return map[string]interface{}{ + "heap_alloc": ms.HeapAlloc, + "heap_inuse": ms.HeapInuse, + "goroutines": runtime.NumGoroutine(), + } } diff --git a/dto/health.go b/dto/health.go index c1ba13b..c479603 100644 --- a/dto/health.go +++ b/dto/health.go @@ -1,5 +1,8 @@ -package dto +package dto +// HealthResponse 是 /health 端点的 JSON 响应。 +// 数值类信息(请求统计、错误)迁移到 /metrics 端点, +// 这里只保留运行期最关键的状态。 type HealthResponse struct { Status string `json:"status"` Service string `json:"service"` @@ -7,24 +10,9 @@ type HealthResponse struct { Uptime string `json:"uptime"` StartTime string `json:"start_time"` Memory map[string]interface{} `json:"memory"` - APIStats APIStatsResponse `json:"api_stats"` - Errors ErrorResponse `json:"errors"` ConfigStatus ConfigStatusResponse `json:"config_status"` } -type APIStatsResponse struct { - TotalRequests int `json:"total_requests"` - SuccessfulRequests int64 `json:"successful_requests"` - FailedRequests int64 `json:"failed_requests"` - ErrorRatePercent string `json:"error_rate_percent"` - AvgResponseTimeMs string `json:"avg_response_time_ms"` - RecentResponseTimesMs []float64 `json:"recent_response_times_ms"` -} - -type ErrorResponse struct { - RecentErrorsCount int `json:"recent_errors_count"` -} - type ConfigStatusResponse struct { AllRequiredVarsSet bool `json:"all_required_vars_set"` ConfigError bool `json:"config_error"` diff --git a/dto/tts.go b/dto/tts.go index fedaff8..402eb1f 100644 --- a/dto/tts.go +++ b/dto/tts.go @@ -1,7 +1,10 @@ -package dto +package dto import "time" +// OpenAITTSRequest 是 /v1/audio/speech 接收的请求体。 +// 仅 input / speed / response_format 实际影响火山侧; +// voice / model 当前保留接收但不做映射,详见 controller。 type OpenAITTSRequest struct { Model string `json:"model"` Input string `json:"input"` @@ -10,6 +13,7 @@ type OpenAITTSRequest struct { Speed float64 `json:"speed,omitempty"` } +// V3TTSResponse 是火山 v3 HTTP Chunked 流式响应中每一行的 JSON 结构。 type V3TTSResponse struct { ReqID string `json:"reqid"` Code int `json:"code"` @@ -22,20 +26,39 @@ type V3TTSResponse struct { Usage *V3Usage `json:"usage,omitempty"` } +// V3Usage 由 X-Control-Require-Usage-Tokens-Return 触发,包含计费字符数。 type V3Usage struct { TextWords int `json:"text_words"` } +// ByteDanceTTSConfig 是 setting 包的全局 TTS 配置,目前只承载鉴权 / URL / 超时; +// 完整的合成参数见 adapter/volcano.Options。 type ByteDanceTTSConfig struct { ApiKey string ResourceId string - Speaker string URL string Timeout time.Duration } +// SynthesisResult 是火山适配器向 controller 返回的最终结果。 +// Format 与 AudioData 的实际编码一致;controller 据此设置响应 Content-Type。 type SynthesisResult struct { - AudioData []byte - ReqID string - Format string // 实际输出格式,用于设置 Content-Type + AudioData []byte + Format string + SampleRate int + ReqID string + TextWords int // 来自 V3Usage,无 usage 时为 0 + Chunks int // 实际收到的音频 chunk 数 + AudioBytes int // 解码后总字节数 + TTFB time.Duration // 收到首个音频 chunk 的耗时 + Duration time.Duration // 整体合成耗时 +} + +// SubtitleEntry 描述一个字级时间戳条目(当 enable_subtitle / enable_timestamp 启用时返回)。 +type SubtitleEntry struct { + Text string + StartMs int + EndMs int + Sequence int + // 原始事件可能为不同形态,这里只保留通用字段 } diff --git a/health.html b/health.html index ff92635..2a28df5 100644 --- a/health.html +++ b/health.html @@ -1,4 +1,4 @@ - +
@@ -7,241 +7,73 @@ @@ -250,14 +82,15 @@