GO语言:后端如何建立中转服务,以实现在前端与PBX服务器之间充当桥梁,帮助搭建WebRTC连接并传输和处理事件?

此处PBX请参考github.com/restsend/ru...

关于WebRTC等通讯概念,可参考 告别"只闻其名"!一文带你深入浅出 WebRTC,并用 Go 搭建你的第一个实时应用WebRTC

一、 实现思路

1. 前端主动向后端申请建立WebSocket连接,后端升级协议后,再主动向PBX申请建立WebSocket连接。当两段WebSocket建立好后,即可开始准备建立WebRTC连接

为了便于说明,我将后端与前端之间的 WebSocket 连接称为 frontendConn;将后端与 PBX 之间的 WebSocket 连接称为 pbxConn

2. 建立WebRTC
  1. 发送offer:前端在frontendConn连接中,向后端写入Command invite(其中包含前端本地的offer)。后端读取Command invite并将其数据进行处理,之后再重新构建并转发到pbxConn连接中,此处贴出一份示例代码

    go 复制代码
    // 读取前端传来的数据
    _, message, err := s.frontendConn.conn.ReadMessage()
    if err != nil {
        log.Printf("前后端 WebSocket 连接断开: %v", err)
        _ = s.frontendConn.WriteJSON(newErrorResponse(500, "前后端 WebSocket 连接断开"))
        return
    }
    ​
    // 获取数据中的Command字段
    var command Command
    if err := json.Unmarshal(message, &command); err != nil {
        log.Println("解析前端 command 失败")
        _ = s.frontendConn.WriteJSON(newErrorResponse(500, "解析前端 command 失败"))
        return
    }
    ​
    // 如果Command是invite,则对其进行处理和转发
    switch command.Command {
    case "invite":
        // FrontRequest是自定义的结构体,可根据项目需要让前端传进一些指定数据
        // 此处的结构体中有Command字段,Offer字段(值为前端设备的SDP)等等
        var frontReq FrontRequest
        err := json.Unmarshal(message, &frontReq)
        
        // Request结构体用于专门发送offer,PBX服务器可识别
        // 此处vad、asr、tts的参数均为配置参数
        var req Request
        option := CallOption{
           Denoise: false,
           Offer:   frontReq.Offer,             // 前端传来的offer
           Vad: VadOption{                      // Vad为检测用户语音模块,判断用户是否说话
              Type:           "webrtc",
              Samplerate:     16000,
              SpeechPadding:  120,
              SilencePadding: 200,
           },
           Asr: AsrOption{                      // Asr为语音转文本
              Provider:  "tencent",
              ModelType: "16k_zh",
              Language:  "zh-CN",
              AppID:     os.Getenv("APPID"),
              SecretID:  os.Getenv("SECRET_ID"),
              SecretKey: os.Getenv("SECRET_KEY"),
              Endpoint:  os.Getenv("ASR_ENDPOINT"),
           },
           Tts: TtsOption{                      // Tts为语音合成
              Provider:   "tencent",
              Speaker:    frontReq.Speaker,
              Samplerate: 16000,
              AppID:      os.Getenv("APPID"),
              SecretID:   os.Getenv("SECRET_ID"),
              SecretKey:  os.Getenv("SECRET_KEY"),
              Endpoint:   os.Getenv("TTS_ENDPOINT"),
           },
        }
    ​
        req.Command = frontReq.Command        // 将invite写入Request中,PBX服务器根据识别到的Command处理事件
        req.Option = option
        
        ------略------
    ​
        // 将处理好的offer,通过后端与PBX之间的WebSocket连接发送到PBX
        _ = s.pbxConn.WriteJSON(req)
    ​
  2. 接收answer:待前端offer被后端转发到PBX后,后端需要在 pbxConn 连接中读取PBX发来的Event answer,即读取answer事件(PBX会将其SDP等信息写入answer事件中);后端成功读取时即可对其进行简单处理,之后将其写入 frontendConn 连接之中,让前端去接受来自PBX的answer。其中,在Offer/Answer交换的同时,双方进行ICE候选,待收集到足够的ICE候选地址以及接收到对方的SDP(对于发送端,叫做offer,对于接收端,叫做answer),即可进行连接尝试,直至成功建立WebRTC。 至此,后端完成作为信令中转的使命。此处我们贴出后端处理answer的示例代码。

    go 复制代码
    // 在与PBX建立的WebSocket通道中读取对方传来的数据
    _, message, err := s.pbxConn.conn.ReadMessage()
          if err != nil {
            log.Printf("PBX 连接断开: %v", err)
            return
          }
        
          // 识别事件类型
          var ev Event
          err = json.Unmarshal(message, &ev)
          if err != nil {
            log.Printf("解析事件类型失败: %v,原始消息: %s", err, string(message))
          }
    ​
          switch ev.Event {
          // 成功识别到answer事件
          case "answer":
            // 此处的AnswerEvent结构体,必须包含sdp字段,其余可按需求添加
            var answer AnswerEvent
            err := json.Unmarshal(message, &answer)
            if err != nil {
              log.Printf("解析answer事件失败: %v,原始消息: %s", err, string(message))
              _ = s.frontendConn.WriteJSON(newErrorResponse(500, "解析 answer 事件失败,建立RTC连接失败"))
              break
            }
            
            // 成功处理answer,将其包含sdp的数据写入与前端建立的WebSocket连接中,让前端获取来自PBX的answer
            _ = s.frontendConn.WriteJSON(answer)
        
3. 处理事件,(此处我们以用户与AI之间进行语音通话作为应用场景)包含语音转文本、文本合成语音、调用LLM这三个主要的事件,而这三个主要事件可被封装在一起,如下所示,仅供参考。
  • 处理用户语音输入。

    1. 实现思路

      前端浏览器捕获用户输入的音频,并将其通过WebRTC将音频流传输到PBX中,让其将音频流转成文本。之后PBX通过 pbxConn 连接,将转成的文本发送到后端,后端接收到文本后,一方面将文本发送到前端(通过 frontendConn 连接)让前端实时显示用户的输入,另一方面使用文本去调用LLM,来获取ai的回复,所以后端只需处理来自PBX的asr结果。

    2. 此处贴出代码示例

      go 复制代码
      // 在与PBX建立的WebSocket通道中读取对方传来的数据
      _, message, err := s.pbxConn.conn.ReadMessage()
            if err != nil {
              log.Printf("PBX 连接断开: %v", err)
              return
            }
      ​
            // 识别事件类型
            var ev Event
      ​
            err = json.Unmarshal(message, &ev)
            if err != nil {
              log.Printf("解析事件类型失败: %v,原始消息: %s", err, string(message))
            }
      ​
            switch ev.Event {
      ​
            // PBX成功转换成音频后,会发送两种事件。
            // 一是asrFinal,即将文本一次性都写在这个事件里通过WebSocket发送到后端;
            // 二是asrDelta,即将文本按照流式写入这个事件里发送到后端;此处我们读取asrFinal
            case "asrFinal":
              var asrFinal AsrFinalEvent
              err := json.Unmarshal(message, &asrFinal)
              if err != nil {
                log.Printf("解析asrFinal事件失败: %v,原始消息: %s", err, string(message))
                _ = s.frontendConn.WriteJSON(newErrorResponse(500, "解析 asrFinal 事件失败,用户音频暂无法调用LLM"))
                break
              }
      ​
              // 将用户文本写入到自定义的pbx_text_response事件
              respToFrontend := ToFrontASRResp{
                Event:     "pbx_text_response",
                Text:      asrFinal.Text,
                GmtCreate: time.Now().Format("2006-01-02 15:04:05"),
              }
      ​
              // 将用户文本通过与前端建立的WebSocket发送到前端,让前端去识别
              _ = s.frontendConn.WriteJSON(respToFrontend)
      ​
              // 将用户文本拿去调用LLM,其中返回结果处理会在下面几步中说明,逻辑都封装在了这个方法里,仅供参考
              go s.processStreamLLMRequest(asrFinal.Text)
  • 调用LLM并将结果分别传输到前端(用于在前端页面中显示文本)和PBX(用于tts语音合成,让前端播放ai音频)

    1. 实现思路

      将asr事件的结果(用户语音转成的文本)作为参数去调用LLM,后端随后将LLM返回值处理,并分别发送到 frontendConn 连接和 pbxConn 连接中。

    2. 此处贴出示例代码,调用的LLM以非流式输出为例

      go 复制代码
      // 处理语音转文本后的LLM非流式请求
      func (s *Session) processLLMRequest(text string) {
      ​
        client := llm.NewLLMClient()
        llmService := service.NewLLMService(client)
      ​
        // 获取历史记录(从数据库)
        if globalDB == nil {
          log.Printf("数据库连接未初始化,跳过历史记录")
          return
        }
      ​
        // 获取对话上下文
        historyRepo := repository.NewHistoryRepo(globalDB)
        assistantRepo := repository.NewAssistantRepo(globalDB)
        historyService := service.NewHistoryService(historyRepo, assistantRepo)
      ​
        histories, err := historyService.SelectByAssistantID(context.Background(), s.assistantID)
        if err != nil {
          log.Printf("获取历史记录失败: %v", err)
          histories = []model.History{} // 使用空历史记录
        }
      ​
        // 转换为调用通义千问的LLM消息格式
        history := model.HistoryToLLMMessages(histories)
      ​
        // 调用LLM,处理逻辑封装,此处将对话上下文和asr的语音文本拼接,去调用LLM
        result, err := llmService.GenerateResponseWithFunctionCalling(s.prompt, text, s.indexID, s.Stream, history)
      ​
        if err != nil {
          log.Printf("LLM调用失败: %v", err)
          return
        }
      ​
        // 获取ai文本回复
        respText := result.AIReply
      ​
        // 构建发送到前端的结构体
        responseToFront := ToFrontLLMResp{
          Event:        "llm_response",
          Text:         respText,
          InputTokens:  result.InputTokens,
          OutputTokens: result.OutputTokens,
          TotalTokens:  result.TotalTokens,
          GmtCreate:    time.Now().Format("2006-01-02 15:04:05"),
        }
      ​
        s.ttsPlaying = true
        
        // 发送到前端
        _ = s.frontendConn.WriteJSON(responseToFront)
      ​
      ​
        // 构建tts结构体,PBX会识别到tts命令,之后会将Text字段的文本合成为音频
        var ttsCommand = TtsCommand{
          Command: "tts",
          Text:    respText,
        }
      ​
        // 发送到PBX,启动tts
        _ = s.pbxConn.WriteJSON(ttsCommand)
        log.Printf("tts: %v", ttsCommand)
      ​
        // 保存历史记录到数据库
        historyRecord := model.History{
          AssistantID:     s.assistantID,
          UserInput:       text,
          Prompt:          s.prompt,
          AssistantOutput: respText,
          InputTokens:     result.InputTokens, // 可以从LLM响应中获取
          OutputTokens:    result.OutputTokens,
          TotalTokens:     result.TotalTokens,
          FinishReason:    result.FinishReason,
          GmtCreate:       time.Now().Format("2006-01-02 15:04:05"),
        }
      ​
        if err := historyService.Save(context.Background(), historyRecord); err != nil {
          log.Printf("保存历史记录失败: %v", err)
        }
      }

二、总结

此推文所涉及到的场景仅为一条简单的示例,本文主要讲解的是在建立WebRTC服务中,后端作为中转的一种实现思路。若有纰漏,欢迎大家批评指正!

相关推荐
科技语者Code2 小时前
深入解析模型上下文协议 (MCP):架构、流程与应用实践
人工智能·架构
VisuperviReborn2 小时前
打造自己的前端监控---前端流量监控
前端·设计模式·架构
用户84913717547162 小时前
JustAuth实战系列(第1期):项目概览与价值分析
java·架构·开源
阿拉斯加大闸蟹2 小时前
DPDK全科普
架构
自由的疯3 小时前
Java 17 新特性之 instanceof 运算符
java·后端·架构
自由的疯3 小时前
Java 17 新特性之 Switch 表达式改进
java·后端·架构
是店小二呀3 小时前
软件开发中,如何高效避免内存泄漏
架构
文火冰糖的硅基工坊3 小时前
[硬件电路-140]:模拟电路 - 信号处理电路 - 锁定放大器概述、工作原理、常见芯片、管脚定义
嵌入式硬件·架构·信号处理·电路·跨学科融合
DemonAvenger4 小时前
Go中Protocol Buffers与JSON的网络数据序列化
网络协议·架构·go