feat: 弃用腾讯tts,该用火山引擎tts

This commit is contained in:
Blizzard
2026-03-20 17:06:19 +08:00
parent e4b7ee04cc
commit f4bfe2d609
13 changed files with 294 additions and 173 deletions
+170 -133
View File
@@ -1,7 +1,9 @@
package radio
import (
"bytes"
"crypto/md5"
"encoding/json"
"fmt"
"io"
"net/http"
@@ -11,228 +13,263 @@ import (
"sundynix-go/global"
"sundynix-go/model/radio"
"sundynix-go/model/system"
"sundynix-go/pkg/httpclient"
"sundynix-go/utils/upload"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
tts "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/tts/v20190823"
"github.com/google/uuid"
)
// TTSService TTS服务
type TTSService struct{}
// TTSTextToSpeechRequest TTS请求参数
type TTSTextToSpeechRequest struct {
// TTSRequest TTS请求参数
type TTSRequest struct {
Text string // 要转换的文本
VoiceType int // 声音类型
Speed int // 语速 -2到6
Volume int // 音量 -10到10
Speaker string // 声音类型
ProgramId string // 节目ID
}
// TTSResult TTS任务结果
type TTSResult struct {
TaskId string // 任务ID
ProgramId string // 节目ID
Status int // 0:处理中 1:成功 2:失败
StatusMsg string // 状态描述
AudioId string // OSS文件ID
AudioUrl string // OSS文件URL
CreatedAt time.Time // 创建时间
}
var TTSServiceApp = new(TTSService)
// newTTSClient 创建腾讯云TTS客户端
func (t *TTSService) newTTSClient() (*tts.Client, error) {
credential := common.NewCredential(
global.Config.TTS.SecretId,
global.Config.TTS.SecretKey,
)
cpf := profile.NewClientProfile()
cpf.HttpProfile.Endpoint = "tts.tencentcloudapi.com"
return tts.NewClient(credential, "ap-guangzhou", cpf)
}
// SubmitTTSTask 提交长文本TTS任务 (异步)
func (t *TTSService) SubmitTTSTask(req TTSTextToSpeechRequest) (string, error) {
func (t *TTSService) SubmitTTSTask(req TTSRequest) (string, error) {
if req.Text == "" {
return "", fmt.Errorf("文本内容不能为空")
}
// 提交长文本TTS任务
taskId, err := t.doSubmitTTSTask(req)
if err != nil {
return "", fmt.Errorf("提交TTS任务失败: %v", err)
}
// 异步处理结果
go t.asyncProcessResult(req.ProgramId, taskId)
return taskId, nil
}
// doSubmitTTSTask 提交任务
func (t *TTSService) doSubmitTTSTask(req TTSRequest) (string, error) {
url := "https://openspeech.bytedance.com/api/v3/tts/submit"
appID := global.Config.TTS.AppId
accessKey := global.Config.TTS.AccessKey
resourceID := global.Config.TTS.ResourceId
if resourceID == "" {
resourceID = "seed-tts-2.0"
}
speaker := req.Speaker
if speaker == "" {
speaker = "zh_male_dayi_uranus_bigtts"
}
bodyData := map[string]interface{}{
"user": map[string]interface{}{"uid": "123123"},
"unique_id": uuid.New().String(),
"req_params": map[string]interface{}{
"text": req.Text,
"speaker": speaker,
"audio_params": map[string]interface{}{
"format": "mp3",
"sample_rate": 24000,
"enable_timestamp": true,
},
"additions": "{}",
},
}
jsonBody, _ := json.Marshal(bodyData)
httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody))
if err != nil {
return "", err
}
httpReq.Header.Set("X-Api-App-Id", appID)
httpReq.Header.Set("X-Api-Access-Key", accessKey)
httpReq.Header.Set("X-Api-Resource-Id", resourceID)
httpReq.Header.Set("X-Api-Request-Id", uuid.New().String())
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Connection", "keep-alive")
resp, err := httpclient.GetClient().Do(httpReq)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBytes, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("请求失败, 状态码: %d, 返回: %s", resp.StatusCode, string(respBytes))
}
var result struct {
Data struct {
TaskId string `json:"task_id"`
} `json:"data"`
Message string `json:"message"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if result.Data.TaskId == "" {
return "", fmt.Errorf("未能获取任务ID: %s", result.Message)
}
return result.Data.TaskId, nil
}
// asyncProcessResult 异步处理TTS结果
func (t *TTSService) asyncProcessResult(programId, taskId string) {
// 轮询查询任务结果,获取音频下载URL
resultUrl, err := t.waitForResult(taskId)
if err != nil {
global.Logger.Error(fmt.Sprintf("TTS任务失败, TaskId: %s, Error: %v", taskId, err))
global.DB.Model(&radio.RadioProgram{}).Where("id = ?", programId).Update("audio_status", 0)
return
}
// 从ResultUrl下载音频数据
audioData, err := t.downloadAudio(resultUrl)
if err != nil {
global.Logger.Error(fmt.Sprintf("下载音频失败, TaskId: %s, Error: %v", taskId, err))
global.DB.Model(&radio.RadioProgram{}).Where("id = ?", programId).Update("audio_status", 0)
return
}
// 上传到OSS
audioId, err := t.uploadToOSS(audioData, programId)
if err != nil {
global.Logger.Error(fmt.Sprintf("上传OSS失败, TaskId: %s, Error: %v", taskId, err))
global.DB.Model(&radio.RadioProgram{}).Where("id = ?", programId).Update("audio_status", 0)
return
}
// 更新节目的audio_id
if err := global.DB.Model(&radio.RadioProgram{}).Where("id = ?", programId).
Update("audio_id", audioId).Error; err != nil {
Updates(map[string]interface{}{
"audio_id": audioId,
"audio_status": 2, // 音频就绪
}).Error; err != nil {
global.Logger.Error(fmt.Sprintf("更新节目音频ID失败, TaskId: %s, Error: %v", taskId, err))
global.DB.Model(&radio.RadioProgram{}).Where("id = ?", programId).Update("audio_status", 0)
return
}
global.Logger.Info(fmt.Sprintf("TTS任务完成, TaskId: %s, ProgramId: %s, AudioId: %s", taskId, programId, audioId))
}
// SubmitTTSTaskOnly 仅提交任务,返回TaskId (供外部调用)
func (t *TTSService) SubmitTTSTaskOnly(req TTSTextToSpeechRequest) (string, error) {
if req.Text == "" {
return "", fmt.Errorf("文本内容不能为空")
}
return t.doSubmitTTSTask(req)
}
// QueryTTSTask 查询任务状态
func (t *TTSService) QueryTTSTask(taskId string) (int, string, error) {
_, status, statusMsg, err := t.queryTaskResult(taskId)
return status, statusMsg, err
}
// GetAudioByTaskId 根据TaskId获取音频下载URL
func (t *TTSService) GetAudioByTaskId(taskId string) ([]byte, error) {
resultUrl, status, _, err := t.queryTaskResult(taskId)
if err != nil {
return nil, err
}
if status != 1 {
return nil, fmt.Errorf("任务未完成或失败")
}
return t.downloadAudio(resultUrl)
}
// doSubmitTTSTask 使用官方SDK提交长文本TTS任务
func (t *TTSService) doSubmitTTSTask(req TTSTextToSpeechRequest) (string, error) {
client, err := t.newTTSClient()
if err != nil {
return "", fmt.Errorf("创建TTS客户端失败: %v", err)
}
request := tts.NewCreateTtsTaskRequest()
request.Text = common.StringPtr(req.Text)
request.VoiceType = common.Int64Ptr(int64(req.VoiceType))
request.Speed = common.Float64Ptr(float64(req.Speed))
request.Volume = common.Float64Ptr(float64(req.Volume))
request.Codec = common.StringPtr("mp3")
request.ModelType = common.Int64Ptr(1)
response, err := client.CreateTtsTask(request)
if err != nil {
return "", fmt.Errorf("调用CreateTtsTask失败: %v", err)
}
if response.Response == nil || response.Response.Data == nil || response.Response.Data.TaskId == nil {
return "", fmt.Errorf("未获取到TaskId")
}
return *response.Response.Data.TaskId, nil
}
// waitForResult 轮询查询任务结果,返回音频下载URL
func (t *TTSService) waitForResult(taskId string) (string, error) {
maxRetries := 30
interval := 15 * time.Second
maxRetries := 10
interval := 30 * time.Second
for i := 0; i < maxRetries; i++ {
time.Sleep(interval)
resultUrl, status, statusMsg, err := t.queryTaskResult(taskId)
if err != nil {
return "", err
}
switch status {
case 1: // 成功
case 1:
return resultUrl, nil
case 2: // 失败
case 2:
return "", fmt.Errorf("TTS合成失败: %s", statusMsg)
default:
global.Logger.Debug(fmt.Sprintf("TTS任务处理中, TaskId: %s, 重试次数: %d", taskId, i+1))
}
}
return "", fmt.Errorf("TTS任务超时, TaskId: %s", taskId)
}
// queryTaskResult 使用官方SDK查询任务结果
// 返回: resultUrl, status, statusMsg, error
// status: 0-等待中 1-成功 2-失败 3-执行中
// queryTaskResult 查询任务状态
func (t *TTSService) queryTaskResult(taskId string) (string, int, string, error) {
client, err := t.newTTSClient()
url := "https://openspeech.bytedance.com/api/v3/tts/query"
appID := global.Config.TTS.AppId
accessKey := global.Config.TTS.AccessKey
resourceID := global.Config.TTS.ResourceId
if resourceID == "" {
resourceID = "seed-tts-2.0"
}
bodyData := map[string]interface{}{"task_id": taskId}
jsonBody, _ := json.Marshal(bodyData)
httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody))
if err != nil {
return "", 0, "", fmt.Errorf("创建TTS客户端失败: %v", err)
return "", 0, "", err
}
request := tts.NewDescribeTtsTaskStatusRequest()
request.TaskId = common.StringPtr(taskId)
httpReq.Header.Set("X-Api-App-Id", appID)
httpReq.Header.Set("X-Api-Access-Key", accessKey)
httpReq.Header.Set("X-Api-Resource-Id", resourceID)
httpReq.Header.Set("X-Api-Request-Id", uuid.New().String())
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Connection", "keep-alive")
response, err := client.DescribeTtsTaskStatus(request)
resp, err := httpclient.GetClient().Do(httpReq)
if err != nil {
return "", 0, "", fmt.Errorf("调用DescribeTtsTaskStatus失败: %v", err)
return "", 0, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBytes, _ := io.ReadAll(resp.Body)
return "", 0, "", fmt.Errorf("查询任务失败, 状态码: %d, 返回: %s", resp.StatusCode, string(respBytes))
}
if response.Response == nil || response.Response.Data == nil {
return "", 0, "", fmt.Errorf("查询任务状态返回为空")
respBytes, _ := io.ReadAll(resp.Body)
global.Logger.Info(fmt.Sprintf("火山查询原始结果: %s", string(respBytes)))
var result struct {
Data map[string]interface{} `json:"data"`
Message string `json:"message"`
}
if err := json.Unmarshal(respBytes, &result); err != nil {
return "", 0, "", err
}
data := response.Response.Data
// 将SDK的StatusStr转换为数字状态
status := 0
statusMsg := ""
if data.StatusStr != nil {
statusMsg = *data.StatusStr
switch *data.StatusStr {
case "success":
status = 1
case "failed":
status = 2
case "waiting", "doing":
status = 0
audioUrl := ""
if result.Data != nil {
// 官方文档定义:
// 1: Running (正在处理)
// 2: Success (处理成功)
// 3: Failure (处理失败)
volcStatus := 0
if statusVal, ok := result.Data["task_status"].(float64); ok {
volcStatus = int(statusVal)
} else if statusStr, ok := result.Data["task_status"].(string); ok {
if statusStr == "1" {
volcStatus = 1
} else if statusStr == "2" || statusStr == "success" || statusStr == "done" {
volcStatus = 2
} else if statusStr == "3" || statusStr == "failed" || statusStr == "error" {
volcStatus = 3
}
}
// 映射到内部状态: 0: 处理中, 1: 成功, 2: 失败
if volcStatus == 1 {
status = 0 // Running
} else if volcStatus == 2 {
status = 1 // Success
} else if volcStatus == 3 {
status = 2 // Failure
}
if val, ok := result.Data["audio_url"].(string); ok {
audioUrl = val
} else if val, ok := result.Data["audio"].(string); ok {
audioUrl = val
}
}
resultUrl := ""
if data.ResultUrl != nil {
resultUrl = *data.ResultUrl
if audioUrl != "" {
status = 1
}
return resultUrl, status, statusMsg, nil
if status == 1 && audioUrl == "" {
// 任务状态为1时如果没有url,继续等待(轮询)避免直接返回空URL使下载失败
status = 0
}
return audioUrl, status, result.Message, nil
}
// TTSService TTS服务
type TTSService struct{}
var TTSServiceApp = new(TTSService)
// downloadAudio 从URL下载音频数据
func (t *TTSService) downloadAudio(audioUrl string) ([]byte, error) {
resp, err := http.Get(audioUrl)
resp, err := httpclient.GetClient().Get(audioUrl)
if err != nil {
return nil, fmt.Errorf("下载音频失败: %v", err)
}