科大讯飞实时- 声纹注册和语音识别
"先注册声纹 → 实时转写里开启声纹分离"这个大方向是完全对的,现在主要是把整体流程和各个环节的"职责边界"理顺,再针对"识别内容 + 说话人"做一套稳定的流式方案。
一、整体流程先捋一遍
结合讯飞两篇官方文档,完整链路应该是这样:(讯飞开放平台)
-
离线阶段:注册声纹
-
每个说话人录制 10--60 秒 的干净语音(建议 1--3 段)。
-
服务端把 wav/mp3 → ffmpeg 转成
16kHz / 16bit / 单声道的 PCM(s16le)。 -
把 PCM base64 后,调用
POST /res/feature/v1/register,audio_type=raw。 -
成功后拿到一个
feature_id,写入你自己的业务库,例如:textuser_id = 10001 -> feature_id = 20251130182020880VsrO... user_id = 10002 -> feature_id = 20251130204227512hqsB... -
后面 RTASR 只认这个
feature_id,业务里显示"ford、花火",都是你自己映射。
-
-
在线阶段:实时语音识别 + 说话人分离
-
前端(浏览器/小程序/桌面)录音,保证 采样率 16k, 16bit, 单声道。
-
前端边录边把 PCM 通过 WebSocket 传给你的后端(可以按每 40ms 一帧或者更小的 chunk)。(讯飞开放平台)
-
后端为每个前端连接:
- 根据本次会话需要识别的说话人(比如当前会议参与人),从你自己的 DB 取出它们的 feature_id 列表。
- 组装 RTASR 的 ws URL:
role_type=2(开启说话人分离)feature_ids=逗号分隔的 feature_id 列表eng_spk_match=1(强制角色来自声纹库)(讯飞开放平台)
- 建立到讯飞的 WebSocket 连接。
- 开三个 goroutine 做"桥接":
- A :前端 WS →
pcmChan - B :从
pcmChan取数据,按 1280 字节一帧发给讯飞 - C :从讯飞读 JSON,解析文本和
rl,再把{ text, speaker }推回前端。
- A :前端 WS →
-
前端就能实时收到:
json{ "type": "result", "text": "你好,今天天气不错", "speaker_id": "20251130204227512hqs...", "speaker_name": "huaHuo", "is_final": false }
-
二、前端流式发送音频的思路
官方建议:每 40ms 发送 1280 字节 的 PCM,但这只是"推荐速率",不是"内容边界"。识别引擎会把所有连续的 PCM 当成一条音频流来处理,不会因为你一帧刚好把一个字的波形切开就识别不出来。(讯飞开放平台)
你可以按两种方式实现:
- 边录边发(真正实时)
- 录音 SDK 一般会回调出小块 PCM,比如每 10--20ms 一块。
- 前端做一个缓冲区 buffer:
- 把回调的 PCM append 到 buffer;
- 只要
buffer.length >= 1280就ws.send(buffer.slice(0,1280)); - 再把 buffer 截断为剩余部分。
- 这样就满足"约 40ms 发送一帧",引擎是流式的,语音内容不会被逻辑"切坏"。
- 事先有整段文件(你现在的 test.html)
- 先把 wav/mp3 转成 PCM(浏览器端可用 WebAudio/ffmpeg.wasm),
- 再在前端用 JS 按 1280 字节切成小块,用
setInterval(40ms)或requestAnimationFrame往 WS 发。
无论哪种情况,只要 PCM 顺序正确、采样率/位长/声道正确,识别效果是一样的。
三、后端握手到讯飞 RTASR:关键参数怎么填
你现在的 buildWSURL 基本正确,文档里的关键参数是:(讯飞开放平台)
audio_encode=pcm_s16lesamplerate=16000lang=autodialect- 说话人分离相关 :
role_type=2:打开角色分离(可配合声纹库)feature_ids=xxx,yyy,zzz:你注册得到的feature_id,顺序非常重要eng_spk_match=1:强制角色分离结果时,角色信息全部来自声纹库
✅ 正确做法是:
每次有新会话时,把本次会话要识别的所有说话人 的
feature_id根据某个固定顺序拼成字符串,然后调用buildWSURL。
比如:
go
// 从 DB 按固定顺序读出本房间两个用户
featureIDsSlice := []string{
"20251130182020880VsrOXyfSZ9Yf62fD", // ford
"20251130204227512hqsBg6rnRLiJUGJz", // huaHuo
}
featureIDs := strings.Join(featureIDsSlice, ",")
然后把 featureIDs 传给 buildWSURL 去生成 URL。
四、后端解析结果 → 文本 + 发音人
文档里说明:转写结果结构如下,data.cn.st.rt.ws.cw.w 是词,rl 是 角色分离标识 ,只有开启角色分离才有。(讯飞开放平台)
- 一般会是一个 整数 :
0 / 1 / 2 / ...,表示"第几个说话人"。 - 开启声纹分离并传了
feature_ids后,这个rl会和你传入的 feature_id 顺序关联起来。
一个常见的解析策略(你现在代码已经接近了):
go
// buildWSURL 时用的 featureIDsSlice
var featureIDsSlice = strings.Split(featureIDs, ",")
func parseResult(m wsMessage) {
st := m.Data.CN.ST
isFinal := m.Data.LS != nil && *m.Data.LS
for _, rtBlock := range st.RT {
for _, wsItem := range rtBlock.WS {
var sb strings.Builder
roleIdx := -1
for _, cw := range wsItem.CW {
if cw.W == "" {
continue
}
sb.WriteString(cw.W)
if cw.RL != "" {
if v, err := strconv.Atoi(cw.RL); err == nil {
roleIdx = v // 🔴 文档示例中从 0 开始
}
}
}
if sb.Len() == 0 {
continue
}
text := sb.String()
var speakerID, speakerName string
if roleIdx >= 0 && roleIdx < len(featureIDsSlice) {
speakerID = strings.TrimSpace(featureIDsSlice[roleIdx])
speakerName = speakerNameByFeatureID[speakerID]
}
log.Printf("【RTASR结果】text=%s | rl=%d | speakerName=%s | speakerID=%s | final=%v",
text, roleIdx, speakerName, speakerID, isFinal)
out := OutResult{
Type: "result",
Text: text,
SpeakerID: speakerID,
SpeakerName: speakerName,
IsFinal: isFinal,
}
// 发给前端......
}
}
}
🔍 如果你看到
rl一直是0,而你传入了两个feature_id,那说明引擎认为这一整段都更像第 0 个声纹,不是解析 bug,而是声纹相似度或注册质量的问题(见下一节)。
五、你的当前问题:花火的音频却识别成 ford,怎么理解?
在"声纹分离"模式下,引擎的逻辑类似于:
给定一句话的声纹特征,去和
feature_ids里每个声纹做匹配 → 选一个最相似的(或者干脆认定是某个 id)。
所以出现"花火音频被打成 ford",一般有几类原因:
- 花火的声纹注册质量不够
- 时长不够(<10s)或只有一段;(讯飞开放平台)
- 背景噪音大、距离远、设备和识别时差别很大;
- 建议为同一个人录制多段 20--30s 的干净语音,多次注册,让库里有更多样本(具体要看文档或控制台的建议)。
- 注册和识别时的音频链路不一致
- 注册时是
wav -> ffmpeg -> PCM; - 实时识别时是"浏览器录音 → JS 处理 → PCM"。
如果采样率/通道/位深一致就没问题,但如果一个是 48k downsample,一个本来就是 16k、前端又做了音量归一化,可能导致特征分布差异比较大。
- 注册时是
- 两个人声音 timbre 比较像 + 库里样本少
- 声纹识别一直是"概率问题",不是绝对的。短语音、相近 timbre 时,模型可能偏向某一个。
- feature_ids 顺序不一致
- 要确保:注册返回的 feature_id → 写 DB → 从 DB 读出拼到 feature_ids 的顺序,和你
speakerNameByFeatureID的映射完全一致。 - 你之前代码里
speakerNameByFeatureID的 key 一度写错(用了旧的 feature_id),这个会导致你在控制台看到的speakerName和实际上匹配到的 feature_id 对不上。
- 要确保:注册返回的 feature_id → 写 DB → 从 DB 读出拼到 feature_ids 的顺序,和你
怎么验证到底是"模型判断"还是"代码错误"?
-
在日志中打印 raw 的
rl和feature_id映射:golog.Printf("rl=%d -> featureID=%s", roleIdx, speakerID) -
再对照你在控制台或 DB 中的 feature_id,看有没有"把 huaHuo 的 id 打印出来,但你却映射成 ford"的情况。如果 feature_id 正确但 name 错,那是你业务代码的问题;如果 feature_id 本身就是 ford,那就是模型认为更像 ford。
六、最终的落地方案小结
你可以按下面"小 checklist"来收尾整个方案:
-
声纹注册服务(你现有的 8081 服务)
- 继续用 ffmpeg 转 PCM。
- 确保时长 10--60s;可以在页面上直接提示"录制 20 秒以上"。
-
业务 DB
-
表
voiceprint_user:sqluser_id BIGINT PK feature_id VARCHAR(64) nickname VARCHAR(64) created_at DATETIME -
以后如果支持多段声纹,也可以一人多条 feature_id。
-
-
实时识别 WS 接口(8080)
- URL
/ws/rtasr?room_id=xxx或头里带X-User-Ids: 10001,10002。 - 后端拿到这些 user_id → 查出 feature_id 列表 → 传给 RTASR 的
feature_ids。 role_type=2, eng_spk_match=1写死。
- URL
-
前端
-
录音:16k/16bit/mono。
-
用 WS 把 PCM 流式发给你的
/ws/rtasr。 -
收到后端的 JSON:
json{ "type": "result", "text": "...", "speaker_name": "ford", "is_final": false } -
按
speaker_name分行显示即可。
-
这样整套链路就是:
用户录 20s 声音 → 注册得到 feature_id → 存库
多人实时通话时:前端录音 → 你的后端 → 讯飞 RTASR → 你的后端解析 rl → 前端拿到"谁说了什么"的流式结果。
下面是包括业务角色映射和流式输出说话人和语音内容的完整的示例代码。
七、代码实现
go
package main
import (
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"log"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
)
////////////////////////////////////////////////////////////////////////////////
// 科大讯飞 RTASR 固定配置(替换成你自己的) //
////////////////////////////////////////////////////////////////////////////////
const (
RTASR_APP_ID = "你的AppId"
RTASR_ACCESS_KEY_ID = "你的ACCESS_KEY_ID"
RTASR_ACCESS_KEY_SECRET = "你的访问密钥"
)
// RTASR 基本参数
const (
baseWSURL = "wss://office-api-ast-dx.iflyaisol.com/ast/communicate/v1"
audioFrameSize = 1280 // 16kHz, 16bit, 单声道, 40ms ≈ 1280 字节
defaultLang = "autodialect"
defaultEncode = "pcm_s16le"
defaultRate = "16000"
// 角色分离:2=开启实时角色分离,可配合声纹注册使用
defaultRoleTyp = "2"
// ---------------- 声纹分离相关配置(重点) ----------------
// 请确保 featureIDs 顺序与科大讯飞控制台的声纹列表顺序一致(中间不要有空格)
featureIDs = "20251130182020880VsrOXyfSZ9Yf62fD,20251201121644697wvyonbJRKZYCoPB9"
// 是否强制使用声纹库角色(1=开,0=关),声纹分离场景建议开
enableEngSpkMatch = "1"
)
// featureIDs 拆成切片,方便通过 rl 索引反查
var featureIDList = splitAndTrim(featureIDs) // <<< CHANGED: trim entries on init
// 业务层的映射:feature_id => 你要给前端看的身份名字
var speakerNameByFeatureID = map[string]string{
"20251130182020880VsrOXyfSZ9Yf62fD": "ford",
"20251201121644697wvyonbJRKZYCoPB9": "MaJun",
}
////////////////////////////////////////////////////////////////////////////////
// 解析科大讯飞 RTASR 返回 JSON //
////////////////////////////////////////////////////////////////////////////////
type cwResult struct {
W string `json:"w"` // 文本
WP string `json:"wp"` // 置信度类型
RL string `json:"rl,omitempty"` // 说话人编号(角色分离标识)
LG string `json:"lg,omitempty"` // 识别语种(autominor 时返回)
}
type wsResult struct {
CW []cwResult `json:"cw"`
WB int `json:"wb"` // begin 时间(ms)
WE int `json:"we"` // end 时间(ms)
}
type rtResult struct {
WS []wsResult `json:"ws"`
}
type sentence struct {
BG int `json:"bg"` // 句子起始时间
ED int `json:"ed"` // 句子结束时间
Type string `json:"type"` // "0" 最终结果, "1" 中间结果
RT []rtResult `json:"rt"`
}
type cnResult struct {
ST *sentence `json:"st"`
}
type msgData struct {
SegID int `json:"seg_id"`
CN *cnResult `json:"cn"`
LS *bool `json:"ls"` // 是否最后一帧
}
// 科大讯飞 WebSocket 的顶层消息
type wsMessage struct {
MsgType string `json:"msg_type"` // "result" / "error" / "action" 等
ResType string `json:"res_type"` // "asr" 等
Data *msgData `json:"data"`
}
////////////////////////////////////////////////////////////////////////////////
// 返回给前端的统一结果结构 //
////////////////////////////////////////////////////////////////////////////////
type OutResult struct {
Type string `json:"type"` // 固定 "result"
Text string `json:"text"` // 识别出来的文本片段(合并后的句子)
SpeakerID string `json:"speaker_id,omitempty"` // 声纹ID(feature_id)
SpeakerName string `json:"speaker_name,omitempty"` // 业务中实际的"谁在说话"
IsFinal bool `json:"is_final"` // 是否为最终结果
}
////////////////////////////////////////////////////////////////////////////////
// RTASR URL 构造 & 工具 //
////////////////////////////////////////////////////////////////////////////////
// 构造 RTASR 带签名的 WebSocket URL(已加入声纹分离相关参数)
func buildWSURL(appID, accessKeyID, accessKeySecret string) (string, error) {
params := map[string]string{
"appId": appID,
"accessKeyId": accessKeyID,
"uuid": fmt.Sprintf("%d", time.Now().UnixNano()),
"utc": utcBeijingString(),
"audio_encode": defaultEncode,
"lang": defaultLang,
"samplerate": defaultRate,
// ===== 角色分离 & 声纹分离关键参数 =====
"role_type": defaultRoleTyp, // 2=开启角色分离
"feature_ids": featureIDs, // 声纹ID列表,英文逗号分隔
"eng_spk_match": enableEngSpkMatch, // 1=角色结果全部来自声纹库角色
}
// 1. 按 key 排序
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
// 2. 组装 query string(URL Encode)
var b strings.Builder
first := true
for _, k := range keys {
v := params[k]
if strings.TrimSpace(v) == "" {
continue
}
if !first {
b.WriteByte('&')
}
first = false
b.WriteString(url.QueryEscape(k))
b.WriteByte('=')
b.WriteString(url.QueryEscape(v))
}
baseStr := b.String()
// 3. HMAC-SHA1 签名 + Base64
mac := hmac.New(sha1.New, []byte(accessKeySecret))
mac.Write([]byte(baseStr))
sig := mac.Sum(nil)
signature := base64.StdEncoding.EncodeToString(sig)
fullQuery := baseStr + "&signature=" + url.QueryEscape(signature)
return baseWSURL + "?" + fullQuery, nil
}
// 北京时间 UTC 字符串(文档要求形如 2025-09-04T15:38:07+0800)
func utcBeijingString() string {
loc := time.FixedZone("CST", 8*3600)
now := time.Now().In(loc)
return now.Format("2006-01-02T15:04:05-0700")
}
// 统一给前端返回 error 消息(Text JSON)
func writeClientError(conn *websocket.Conn, msg string) {
_ = conn.WriteMessage(
websocket.TextMessage,
[]byte(fmt.Sprintf(`{"type":"error","message":%q}`, msg)),
)
}
// WebSocket 升级器
var upgrader = websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
////////////////////////////////////////////////////////////////////////////////
// WebSocket 入口:前端流式上传 16k/16bit/mono PCM,实时返回文本+发音人 //
////////////////////////////////////////////////////////////////////////////////
func splitAndTrim(s string) []string { // <<< CHANGED: helper to trim entries
if strings.TrimSpace(s) == "" {
return []string{}
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
t := strings.TrimSpace(p)
if t != "" {
out = append(out, t)
}
}
return out
}
// 约定:
// - 前端通过 WebSocket 不断发送 Binary 消息,每条是 pcm_s16le 16k 单声道的 PCM chunk
// - 前端录音结束时,可以发送一个 Text 消息 "end" 表示结束(也可以直接关闭 ws)
// - 本 handler 会一边把 PCM 流转发给科大讯飞,一边把识别结果流式推回前端
func wsRTASRHandler(c *gin.Context) {
// 1. 升级成 WebSocket
clientConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Println("upgrade error:", err)
return
}
defer clientConn.Close()
log.Println("【wsRTASR-PCM】客户端已连接(实时 PCM 模式)")
// 2. 先连上科大讯飞 RTASR
wsURL, err := buildWSURL(RTASR_APP_ID, RTASR_ACCESS_KEY_ID, RTASR_ACCESS_KEY_SECRET)
if err != nil {
log.Println("buildWSURL error:", err)
writeClientError(clientConn, "科大讯飞 URL 构造失败")
return
}
log.Println("RTASR wsURL:", wsURL)
rtasrConn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
log.Println("dial RTASR error:", err)
writeClientError(clientConn, "连接科大讯飞失败: "+err.Error())
return
}
defer rtasrConn.Close()
log.Println("【wsRTASR-PCM】已连接科大讯飞 RTASR")
// 3. PCM 通道:client -> (chan) -> 科大讯飞
pcmChan := make(chan []byte, 32)
doneChan := make(chan struct{}, 3)
// 4. goroutine A:从客户端读取 PCM chunk,放入 pcmChan
go func() {
defer func() { doneChan <- struct{}{} }()
for {
mt, msg, err := clientConn.ReadMessage()
if err != nil {
log.Println("client ReadMessage error/end:", err)
// 客户端断开或读取出错,认为录音结束
close(pcmChan)
return
}
switch mt {
case websocket.BinaryMessage:
if len(msg) == 0 {
continue
}
// 这里默认前端已经保证:16kHz / 16bit / 单声道 的 PCM
// 为避免后面被修改,复制一份
pcmCopy := make([]byte, len(msg))
copy(pcmCopy, msg)
pcmChan <- pcmCopy
case websocket.TextMessage:
// 如果收到 "end",表示前端主动结束
text := strings.TrimSpace(string(msg))
if strings.EqualFold(text, "end") {
log.Println("【wsRTASR-PCM】收到客户端 end 标记,结束 PCM 输入")
close(pcmChan)
return
}
default:
log.Println("收到非 Binary / Text 消息,忽略")
}
}
}()
// 5. goroutine B:从 pcmChan 读数据,按 1280 字节一帧实时发送给科大讯飞
go func() {
defer func() { doneChan <- struct{}{} }()
// 小缓冲,做"碎片合并"
buf := make([]byte, 0, audioFrameSize*4)
flushFrames := func(force bool) error {
// 一帧一帧送给科大讯飞
for len(buf) >= audioFrameSize {
frame := buf[:audioFrameSize]
buf = buf[audioFrameSize:]
if err := rtasrConn.WriteMessage(websocket.BinaryMessage, frame); err != nil {
return err
}
}
// 如果 force=True,把最后不满一帧的也尽量发出去
if force && len(buf) > 0 {
if err := rtasrConn.WriteMessage(websocket.BinaryMessage, buf); err != nil {
return err
}
buf = buf[:0]
}
return nil
}
for chunk := range pcmChan {
// 追加进缓冲
buf = append(buf, chunk...)
// 只要够一帧就立刻发,保证实时性
if len(buf) >= audioFrameSize {
if err := flushFrames(false); err != nil {
log.Println("rtasr Write frame error:", err)
return
}
}
}
// pcmChan 被 close:说明客户端结束录音/发送
// 把最后的 PCM 发完
if err := flushFrames(true); err != nil {
log.Println("rtasr flush last frames error:", err)
}
// 然后发 end 标记
endMsgBytes, _ := json.Marshal(map[string]any{
"end": true,
"sessionId": fmt.Sprintf("%d", time.Now().UnixNano()),
})
if err := rtasrConn.WriteMessage(websocket.TextMessage, endMsgBytes); err != nil {
log.Println("rtasr send end msg error:", err)
} else {
log.Println("【wsRTASR-PCM】已向 RTASR 发送 end 标记")
}
}()
// 6. goroutine C:科大讯飞 -> 客户端(解析结果 + 控制台打印 + 回推前端)
go func() {
defer func() { doneChan <- struct{}{} }()
// activeRole 保存最近由 "最终结果" 指定的角色(基于 rl>0 的 start 标记,rl 可能为 1-based)
activeRole := -1
// per-role buffers:key=roleIdx(rl 转成 int),value=string.Builder
speakerBuffers := map[int]*strings.Builder{}
for {
_, msg, err := rtasrConn.ReadMessage()
if err != nil {
log.Println("rtasr ReadMessage error:", err)
return
}
// <<< CHANGED: 打印 RTASR 原始消息,便于排查(运行后可注释掉) >>>
log.Println("RTASR RAW MSG:", string(msg))
var m wsMessage
if err := json.Unmarshal(msg, &m); err != nil {
log.Println("rtasr JSON 解析失败:", err, " 原始消息:", string(msg))
continue
}
// 异常 / 非 ASR,原样透传给前端
if m.MsgType == "error" || m.ResType != "asr" {
_ = clientConn.WriteMessage(websocket.TextMessage, msg)
continue
}
if m.MsgType == "result" && m.ResType == "asr" &&
m.Data != nil && m.Data.CN != nil && m.Data.CN.ST != nil {
st := m.Data.CN.ST
isFinal := m.Data.LS != nil && *m.Data.LS
for _, rtBlock := range st.RT {
for _, wsItem := range rtBlock.WS {
var partText strings.Builder
rlSeen := -1 // -1 表示未见到 rl
for _, cw := range wsItem.CW {
if cw.W == "" {
continue
}
partText.WriteString(cw.W)
if cw.RL != "" {
if v, err := strconv.Atoi(cw.RL); err == nil {
rlSeen = v
}
}
}
if partText.Len() == 0 {
continue
}
text := partText.String()
// <<< CHANGED: 更健壮的 rl -> roleIdx 解析(兼容 1-based / 0-based) >>>
roleIdx := -1
if isFinal {
if rlSeen > 0 {
// 先按 1-based 处理(rl=1 表示第一个 feature)
tryIdx := rlSeen - 1
if tryIdx >= 0 && tryIdx < len(featureIDList) {
roleIdx = tryIdx
activeRole = roleIdx
} else if rlSeen >= 0 && rlSeen < len(featureIDList) {
// 兼容厂商可能直接返回 0-based 索引
roleIdx = rlSeen
activeRole = roleIdx
log.Printf("注意:RTASR rl 可能为 0-based,使用 roleIdx=%d (rlSeen=%d)", roleIdx, rlSeen)
} else {
log.Printf("警告:最终结果 rlSeen=%d 无法映射到 featureIDList (len=%d)", rlSeen, len(featureIDList))
roleIdx = -1
}
} else if rlSeen == 0 {
// rl==0 表示持续讲话,采用 activeRole(若存在)
if activeRole >= 0 {
roleIdx = activeRole
} else {
roleIdx = -1
log.Println("最终结果 rl==0,但 activeRole 未知 -> speaker unknown")
}
} else { // rlSeen == -1
if activeRole >= 0 {
roleIdx = activeRole
} else {
roleIdx = -1
log.Println("最终结果未包含 rl 字段,且 activeRole 未知 -> speaker unknown")
}
}
} else {
// 中间结果:不信任 rl
if activeRole >= 0 {
roleIdx = activeRole
} else {
roleIdx = -1
}
}
// 累加到对应 role buffer
bld, ok := speakerBuffers[roleIdx]
if !ok {
tmp := &strings.Builder{}
speakerBuffers[roleIdx] = tmp
bld = tmp
}
bld.WriteString(text)
// 构造要回传的 speaker 信息(若能映射)
var speakerID, speakerName string
if roleIdx >= 0 && roleIdx < len(featureIDList) {
speakerID = strings.TrimSpace(featureIDList[roleIdx])
speakerName = speakerNameByFeatureID[speakerID]
if speakerName == "" {
log.Printf("注意:找不到 speakerName(feature_id=%s),请确认 speakerNameByFeatureID 已配置", speakerID)
}
}
// 如果是最终结果:把该 roleIdx 的 buffer 当作一段发言发送给前端并清空 buffer
if isFinal {
fullText := bld.String()
if fullText != "" {
log.Printf("【RTASR-PCM结果-FINAL】text=%s | rlSeen=%d | roleIdx=%d | speakerName=%s | speakerID=%s",
fullText, rlSeen, roleIdx, speakerName, speakerID)
out := OutResult{
Type: "result",
Text: fullText,
SpeakerID: speakerID,
SpeakerName: speakerName,
IsFinal: true,
}
data, _ := json.Marshal(out)
if err := clientConn.WriteMessage(websocket.TextMessage, data); err != nil {
log.Println("client WriteMessage error:", err)
return
}
}
// 清空该 role 的 buffer
speakerBuffers[roleIdx] = &strings.Builder{}
} else {
// 中间结果:也把当前累计片段回传(is_final = false)
out := OutResult{
Type: "result",
Text: bld.String(),
SpeakerID: speakerID,
SpeakerName: speakerName,
IsFinal: false,
}
if data, _ := json.Marshal(out); clientConn.WriteMessage(websocket.TextMessage, data) != nil {
log.Println("client WriteMessage error:", err)
return
}
}
}
}
}
}
}()
// 7. 任意两个 goroutine 结束后,整体收尾
<-doneChan
log.Println("【wsRTASR-PCM】连接即将关闭")
}
func main() {
r := gin.Default()
// 流式 PCM 识别接口
r.GET("/ws/rtasr", wsRTASRHandler)
addr := ":8080"
log.Println("RTASR PCM streaming WebSocket server listening on", addr)
if err := r.Run(addr); err != nil {
log.Fatal(err)
}
}