此处PBX请参考github.com/restsend/ru...
关于WebRTC等通讯概念,可参考 告别"只闻其名"!一文带你深入浅出 WebRTC,并用 Go 搭建你的第一个实时应用WebRTC
一、 实现思路
1. 前端主动向后端申请建立WebSocket连接,后端升级协议后,再主动向PBX申请建立WebSocket连接。当两段WebSocket建立好后,即可开始准备建立WebRTC连接
为了便于说明,我将后端与前端之间的 WebSocket 连接称为 frontendConn;将后端与 PBX 之间的 WebSocket 连接称为 pbxConn
2. 建立WebRTC
-
发送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)
-
接收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这三个主要的事件,而这三个主要事件可被封装在一起,如下所示,仅供参考。
-
处理用户语音输入。
-
实现思路
前端浏览器捕获用户输入的音频,并将其通过WebRTC将音频流传输到PBX中,让其将音频流转成文本。之后PBX通过 pbxConn 连接,将转成的文本发送到后端,后端接收到文本后,一方面将文本发送到前端(通过 frontendConn 连接)让前端实时显示用户的输入,另一方面使用文本去调用LLM,来获取ai的回复,所以后端只需处理来自PBX的asr结果。
-
此处贴出代码示例
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音频)
-
实现思路
将asr事件的结果(用户语音转成的文本)作为参数去调用LLM,后端随后将LLM返回值处理,并分别发送到 frontendConn 连接和 pbxConn 连接中。
-
此处贴出示例代码,调用的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服务中,后端作为中转的一种实现思路。若有纰漏,欢迎大家批评指正!