[Go]GopherAI项目学习记录:TTS功能拓展

一个网页AI聊天,图像识别项目,使用到的技术栈有,Gin框架,GORM,rabbitmq,redis,eino框架,Vue框架等

项目地址:https://github.com/youngyangyang04/GopherAI


GopherAI第二版在第一版的基础上扩展了RAG服务,文件上传服务,MCP服务调用工具,TTS语言识别功能,下面进行一一梳理

TTS即Text To Speech,提供了将文本转为语音的功能,在这个项目中将AI回复的文本通过标准化的API调用生成高质量的音频,通过前端轮询的方式获取语言资源,任务完成后语音输出

路由层

在/AI路由组中新增了两个TTS相关接口,分别是创建TTS任务和查询TTS任务

func AIRouter(r *gin.RouterGroup) {

    // 聊天相关接口
    {
        r.GET("/chat/sessions", session.GetUserSessionsByUserName)
        r.POST("/chat/send-new-session", session.CreateSessionAndSendMessage)
        r.POST("/chat/send", session.ChatSend)
        r.POST("/chat/history", session.ChatHistory)

        // TTS相关接口
        r.POST("/chat/tts", tts.CreateTTSTask)
        r.GET("/chat/tts/query", tts.QueryTTSTask)

        r.POST("/chat/send-stream-new-session", session.CreateStreamSessionAndSendMessage)
        r.POST("/chat/send-stream", session.ChatStreamSend)
    }

}

控制器层

首先依旧是定义请求与响应JSON的结构体(注意这里的结构体与后面服务层的不同)

type (
    TTSRequest struct {
        Text string `json:"text,omitempty"`
    }
    TTSResponse struct {
        TaskID string `json:"task_id,omitempty"`
        controller.Response
    }
    QueryTTSResponse struct {
        TaskID     string `json:"task_id,omitempty"`
        TaskStatus string `json:"task_status,omitempty"`
        TaskResult string `json:"task_result,omitempty"`
        controller.Response
    }
)

创建TTS任务的handler函数

func CreateTTSTask(c *gin.Context) {
    tts := NewTTSServices()
    req := new(TTSRequest)
    res := new(TTSResponse)
    if err := c.ShouldBindJSON(req); err != nil {
        c.JSON(http.StatusOK, res.CodeOf(code.CodeInvalidParams))
        return
    }

    if req.Text == "" {
        c.JSON(http.StatusOK, res.CodeOf(code.CodeInvalidParams))
        return
    }

    // 创建TTS任务并返回任务ID,由前端轮询查询结果
    taskID, err := tts.ttsService.CreateTTS(c, req.Text)
    if err != nil {
        c.JSON(http.StatusOK, res.CodeOf(code.TTSFail))
        return
    }

    res.Success()
    res.TaskID = taskID
    c.JSON(http.StatusOK, res)

}

查询TTS任务的handler函数

func QueryTTSTask(c *gin.Context) {
    tts := NewTTSServices()
    res := new(QueryTTSResponse)
    taskID := c.Query("task_id")
    if taskID == "" {
        c.JSON(http.StatusOK, res.CodeOf(code.CodeInvalidParams))
        return
    }

    TTSQueryResponse, err := tts.ttsService.QueryTTSFull(c, taskID)
    if err != nil {
        log.Println("语音合成失败", err.Error())
        c.JSON(http.StatusOK, res.CodeOf(code.TTSFail))
        return
    }

    if len(TTSQueryResponse.TasksInfo) == 0 {
        c.JSON(http.StatusOK, res.CodeOf(code.TTSFail))
        return
    }

    res.Success()
    res.TaskID = TTSQueryResponse.TasksInfo[0].TaskID

    // 检查 TaskResult 是否为 nil,避免空指针异常
    if TTSQueryResponse.TasksInfo[0].TaskResult != nil {
        res.TaskResult = TTSQueryResponse.TasksInfo[0].TaskResult.SpeechURL
    }
    res.TaskStatus = TTSQueryResponse.TasksInfo[0].TaskStatus
    c.JSON(http.StatusOK, res)
}

服务层

服务层调用了百度的TTS服务API

首先是定义了TTS请求结构体,后序函数中使用

type TTSRequest struct {
    Text           string `json:"text"`
    Format         string `json:"format"`
    Voice          int    `json:"voice"`
    Lang           string `json:"lang"`
    Speed          int    `json:"speed"`
    Pitch          int    `json:"pitch"`
    Volume         int    `json:"volume"`
    EnableSubtitle int    `json:"enable_subtitle"`
}

一个获取API accesstoken的函数,读取配置文件中的secretkey和apikey使用http请求获取accesstoken,后续函数也会使用

// ------------------ Access Token ------------------

func (s *TTSService) GetAccessToken() string {
    conf := config.GetConfig()

    url := "https://aip.baidubce.com/oauth/2.0/token"
    postData := fmt.Sprintf(
        "grant_type=client_credentials&client_id=%s&client_secret=%s",
        conf.VoiceServiceConfig.VoiceServiceApiKey,
        conf.VoiceServiceConfig.VoiceServiceSecretKey,
    )

    resp, err := http.Post(url, "application/x-www-form-urlencoded", bytes.NewReader([]byte(postData)))
    if err != nil {
        log.Println("get token error:", err)
        return ""
    }
    defer resp.Body.Close()

    body, err := io.ReadAll(resp.Body)
    if err != nil {
        log.Println("read token error:", err)
        return ""
    }

    var tokenResp struct {
        AccessToken string `json:"access_token"`
    }

    if err := json.Unmarshal(body, &tokenResp); err != nil {
        log.Println("unmarshal token error:", err)
        return ""
    }

    return tokenResp.AccessToken
}

创建TTS异步任务并返回taskID的函数,获取accesstoken,序列化创建TTS请求,创建http请求并发送,接收响应解析JSON返回百度分配的TaskID

func (s *TTSService) CreateTTS(ctx context.Context, text string) (string, error) {
    accessToken := s.GetAccessToken()
    if accessToken == "" {
        return "", fmt.Errorf("failed to get access token")
    }

    payload := TTSRequest{
        Text:           text,
        Format:         "mp3-16k",
        Voice:          4194,
        Lang:           "zh",
        Speed:          5,
        Pitch:          5,
        Volume:         5,
        EnableSubtitle: 0,
    }

    bodyBytes, err := json.Marshal(payload)
    if err != nil {
        return "", err
    }

    url := "https://aip.baidubce.com/rpc/2.0/tts/v1/create?access_token=" + accessToken
    req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))
    if err != nil {
        return "", err
    }

    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Accept", "application/json")

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()

    respBody, err := io.ReadAll(resp.Body)
    if err != nil {
        return "", err
    }

    log.Println("[TTS Create] raw:", string(respBody))

    var result TTSCreateResponse
    if err := json.Unmarshal(respBody, &result); err != nil {
        return "", err
    }

    if result.TaskID == "" {
        return "", fmt.Errorf("create tts failed: empty task_id")
    }

    return result.TaskID, nil
}

查询TTS状态的函数,查询到success后会解析返回,不然返回的是空的

// QueryTTSFull 查询官方 TTS 状态,解析完整 JSON
func (s *TTSService) QueryTTSFull(ctx context.Context, taskID string) (*TTSQueryResponse, error) {
    accessToken := s.GetAccessToken()
    if accessToken == "" {
        return nil, fmt.Errorf("failed to get access token")
    }

    reqBody := map[string][]string{
        "task_ids": {taskID},
    }
    bodyBytes, _ := json.Marshal(reqBody)

    url := "https://aip.baidubce.com/rpc/2.0/tts/v1/query?access_token=" + accessToken
    req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))
    if err != nil {
        return nil, err
    }

    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Accept", "application/json")

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    respBody, _ := io.ReadAll(resp.Body)
    log.Println("[TTS Query] raw:", string(respBody))

    // 官方返回原始 JSON
    var rawResp struct {
        LogID     json.Number `json:"log_id"`
        TasksInfo []struct {
            TaskID     string          `json:"task_id"`
            TaskStatus string          `json:"task_status"`
            TaskResult json.RawMessage `json:"task_result,omitempty"`
        } `json:"tasks_info"`
    }

    if err := json.Unmarshal(respBody, &rawResp); err != nil {
        return nil, err
    }

    result := &TTSQueryResponse{
        LogID:     rawResp.LogID.String(),
        TasksInfo: make([]TTSTask, 0, len(rawResp.TasksInfo)),
    }

    for _, t := range rawResp.TasksInfo {
        task := TTSTask{
            TaskID:     t.TaskID,
            TaskStatus: t.TaskStatus,
            TaskResult: nil, // 默认 nil
        }

        if t.TaskStatus == "Success" && len(t.TaskResult) > 0 {
            var r TTSTaskResult
            if err := json.Unmarshal(t.TaskResult, &r); err != nil {
                log.Println("parse task_result error:", err)
                return nil, fmt.Errorf("failed to parse task result: %v", err)
            }
            task.TaskResult = &r
        }

        result.TasksInfo = append(result.TasksInfo, task)
    }

    return result, nil
}

Vue前端

前端部分采用轮询,后端返回的JSON中如果是success就播放音频

const playTTS = async (text) => {
      try {
        // 创建TTS任务
        const createResponse = await api.post('/AI/chat/tts', { text })
        if (createResponse.data && createResponse.data.status_code === 1000 && createResponse.data.task_id) {
          const taskId = createResponse.data.task_id

          // 先等待5秒钟再开始轮询
          await new Promise(resolve => setTimeout(resolve, 5000))

          // 轮询查询任务结果
          const maxAttempts = 30
          const pollInterval = 2000
          let attempts = 0

          const pollResult = async () => {
            const queryResponse = await api.get('/AI/chat/tts/query', { params: { task_id: taskId } })

            if (queryResponse.data && queryResponse.data.status_code === 1000) {
              const taskStatus = queryResponse.data.task_status

              if (taskStatus === 'Success' && queryResponse.data.task_result) {
                // 任务完成,播放音频
                // 后端返回的 task_result 是直接的 URL 字符串
                const audio = new Audio(queryResponse.data.task_result)
                audio.play()
                return true
              } else if (taskStatus === 'Running' ||taskStatus === 'Created' ) {
                // 任务进行中,继续轮询
                attempts++
                if (attempts < maxAttempts) {
                  await new Promise(resolve => setTimeout(resolve, pollInterval))
                  return await pollResult()
                } else {
                  ElMessage.error('语音合成超时')
                  return true
                }
              } else {
                // 其他状态(如失败)
                ElMessage.error('语音合成失败')
                return true
              }
            }

            attempts++
            if (attempts < maxAttempts) {
              await new Promise(resolve => setTimeout(resolve, pollInterval))
              return await pollResult()
            } else {
              ElMessage.error('语音合成超时')
              return true
            }
          }

          await pollResult()
        } else {
          ElMessage.error('无法创建语音合成任务')
        }
      } catch (error) {
        console.error('TTS error:', error)
        ElMessage.error('请求语音接口失败')
      }
    }
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇