feat: 长文本语音合成

This commit is contained in:
Blizzard
2026-03-06 17:39:52 +08:00
parent 2583b5f302
commit dda4d2e1d6
24 changed files with 975 additions and 52 deletions
+3 -6
View File
@@ -13,7 +13,7 @@ type CategoryService struct{}
// GetCategoryList 获取分类列表
func (s *CategoryService) GetCategoryList(info radioReq.GetCategoryList) ([]radio.RadioCategory, int64, error) {
db := global.DB.Model(&radio.RadioCategory{}).Preload("Icon").Preload("Cover")
db := global.DB.Model(&radio.RadioCategory{})
var list []radio.RadioCategory
var total int64
@@ -43,9 +43,6 @@ func (s *CategoryService) GetCategoryTree() ([]radio.RadioCategory, error) {
// Preload("Icon") 和 Preload("Cover") 用于加载 OSS 信息
err := global.DB.Model(&radio.RadioCategory{}).
Preload("Channels", "status = ?", 1). // 只加载启用的频道
Preload("Channels.Cover"). // 级联加载频道的封面
Preload("Icon").
Preload("Cover").
Order("sort desc").
Find(&res).Error
return res, err
@@ -53,14 +50,14 @@ func (s *CategoryService) GetCategoryTree() ([]radio.RadioCategory, error) {
func (s *CategoryService) GetAllCategory() ([]radio.RadioCategory, error) {
var res []radio.RadioCategory
err := global.DB.Find(&res).Preload("Icon").Preload("Cover").Error
err := global.DB.Find(&res).Error
return res, err
}
// GetCategoryById 获取分类详情
func (s *CategoryService) GetCategoryById(id string) (*radio.RadioCategory, error) {
var category radio.RadioCategory
err := global.DB.Where("id = ?", id).Preload("Icon").Preload("Cover").First(&category).Error
err := global.DB.Where("id = ?", id).First(&category).Error
return &category, err
}
+1
View File
@@ -136,6 +136,7 @@ func (s *ChannelService) UpdateChannel(req radioReq.UpdateChannel) error {
"description": req.Description,
"cover": req.Cover,
"tags": req.Tags,
"is_free": req.IsFree,
"is_vip_only": req.IsVipOnly,
"monthly_price": req.MonthlyPrice,
"quarterly_price": req.QuarterlyPrice,
+1
View File
@@ -9,6 +9,7 @@ type ServiceGroup struct {
PayService
OrderService
VipService
TTSService
}
var GroupApp = new(ServiceGroup)
+30
View File
@@ -1,6 +1,7 @@
package radio
import (
"fmt"
"sundynix-go/global"
"sundynix-go/model/radio"
radioReq "sundynix-go/model/radio/request"
@@ -111,3 +112,32 @@ func (s *ProgramService) IncrementPlayCount(id string) error {
return global.DB.Model(&radio.RadioProgram{}).Where("id = ?", id).
UpdateColumn("play_count", gorm.Expr("play_count + ?", 1)).Error
}
// GenerateTTS 生成TTS语音并更新节目 (异步)
func (s *ProgramService) GenerateTTS(programId string) error {
// 1. 获取节目内容
var program radio.RadioProgram
if err := global.DB.Where("id = ?", programId).First(&program).Error; err != nil {
return err
}
if program.Content == "" {
return fmt.Errorf("节目内容为空")
}
// 2. 调用TTS提交任务 (异步,后台处理)
ttsReq := TTSTextToSpeechRequest{
Text: program.Content,
VoiceType: 101021, // 亲和女声
Speed: 0, // 正常语速
Volume: 0, // 正常音量
ProgramId: programId,
}
_, err := TTSServiceApp.SubmitTTSTask(ttsReq)
if err != nil {
return err
}
// 任务已提交,异步处理中
return nil
}
+283
View File
@@ -0,0 +1,283 @@
package radio
import (
"crypto/md5"
"fmt"
"io"
"net/http"
"time"
"sundynix-go/global"
"sundynix-go/model/radio"
"sundynix-go/model/system"
"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"
)
// TTSService TTS服务
type TTSService struct{}
// TTSTextToSpeechRequest TTS请求参数
type TTSTextToSpeechRequest struct {
Text string // 要转换的文本
VoiceType int // 声音类型
Speed int // 语速 -2到6
Volume int // 音量 -10到10
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) {
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
}
// 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))
return
}
// 从ResultUrl下载音频数据
audioData, err := t.downloadAudio(resultUrl)
if err != nil {
global.Logger.Error(fmt.Sprintf("下载音频失败, TaskId: %s, Error: %v", taskId, err))
return
}
// 上传到OSS
audioId, err := t.uploadToOSS(audioData, programId)
if err != nil {
global.Logger.Error(fmt.Sprintf("上传OSS失败, TaskId: %s, Error: %v", taskId, err))
return
}
// 更新节目的audio_id
if err := global.DB.Model(&radio.RadioProgram{}).Where("id = ?", programId).
Update("audio_id", audioId).Error; err != nil {
global.Logger.Error(fmt.Sprintf("更新节目音频ID失败, TaskId: %s, Error: %v", taskId, err))
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 := 5 * 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: // 成功
return resultUrl, nil
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-执行中
func (t *TTSService) queryTaskResult(taskId string) (string, int, string, error) {
client, err := t.newTTSClient()
if err != nil {
return "", 0, "", fmt.Errorf("创建TTS客户端失败: %v", err)
}
request := tts.NewDescribeTtsTaskStatusRequest()
request.TaskId = common.StringPtr(taskId)
response, err := client.DescribeTtsTaskStatus(request)
if err != nil {
return "", 0, "", fmt.Errorf("调用DescribeTtsTaskStatus失败: %v", err)
}
if response.Response == nil || response.Response.Data == nil {
return "", 0, "", fmt.Errorf("查询任务状态返回为空")
}
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
}
}
resultUrl := ""
if data.ResultUrl != nil {
resultUrl = *data.ResultUrl
}
return resultUrl, status, statusMsg, nil
}
// downloadAudio 从URL下载音频数据
func (t *TTSService) downloadAudio(audioUrl string) ([]byte, error) {
resp, err := http.Get(audioUrl)
if err != nil {
return nil, fmt.Errorf("下载音频失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("下载音频HTTP错误: %d", resp.StatusCode)
}
audioData, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取音频数据失败: %v", err)
}
return audioData, nil
}
// uploadToOSS 上传音频到OSS并保存到数据库
func (t *TTSService) uploadToOSS(audioData []byte, programId string) (string, error) {
instance := upload.OssInstance()
minioClient, ok := instance.(*upload.Minio)
if !ok {
return "", fmt.Errorf("获取MinIO客户端失败")
}
key := fmt.Sprintf("audio/%s/%s.mp3", time.Now().Format("2006-01-02"), programId)
filename := fmt.Sprintf("program-%s.mp3", programId)
fileURL, err := minioClient.UploadBytes(audioData, key, "audio/mpeg")
if err != nil {
return "", fmt.Errorf("上传文件到OSS失败: %v", err)
}
hashStr := fmt.Sprintf("%x", md5.Sum(audioData))
oss := system.Oss{
Name: filename,
Url: fileURL,
Key: key,
Suffix: "mp3",
Tag: "mp3",
MD5: hashStr,
}
if err := global.DB.Create(&oss).Error; err != nil {
return "", fmt.Errorf("保存文件记录失败: %v", err)
}
return oss.Id, nil
}
+3 -3
View File
@@ -17,9 +17,9 @@ type VipService struct{}
func (s *VipService) UpdateVipConfig(req request.UpdateVipConfig) error {
updateData := map[string]interface{}{
"price": req.Price,
"discountedPrice": req.DiscountedPrice,
"remark": req.Remark,
"price": req.Price,
"discounted_price": req.DiscountedPrice,
"remark": req.Remark,
}
err := global.DB.Model(&radio.Vip{}).Where("id = ?", req.Id).Updates(updateData).Error
return err