218 lines
6.0 KiB
Go
218 lines
6.0 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"compress/gzip"
|
||
"context"
|
||
"encoding/binary"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"sync"
|
||
|
||
"github.com/gorilla/websocket"
|
||
)
|
||
|
||
// volcASR: 火山引擎大模型流式语音识别(Doubao-Seed-ASR-Streaming / sauc bigmodel)。
|
||
// 二进制帧: [4B header][可选4B sequence][4B payload size][payload]
|
||
// header: ver<<4|hdrUnits, msgType<<4|flags, ser<<4|comp, 0x00
|
||
const (
|
||
volcURL = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel"
|
||
|
||
msgFullClient = 0x1
|
||
msgAudioOnly = 0x2
|
||
msgFullServer = 0x9
|
||
msgError = 0xF
|
||
|
||
flagNoSeq = 0x0 // full client request 用
|
||
flagLast = 0x2 // 客户端最后一包音频(负包,无序号)
|
||
flagNegWithSeq = 0x3 // 服务端最终帧(负包+序号)
|
||
serJSON = 0x1
|
||
compNone = 0x0
|
||
compGzip = 0x1
|
||
)
|
||
|
||
type volcASR struct {
|
||
cfg agentCfg
|
||
conn *websocket.Conn
|
||
out chan asrEvent
|
||
wmu sync.Mutex
|
||
once sync.Once
|
||
emitted map[int]bool // 已作为 final 发出的 utterance 下标
|
||
}
|
||
|
||
func newVolcASR(cfg agentCfg) *volcASR {
|
||
return &volcASR{cfg: cfg, out: make(chan asrEvent, 64), emitted: map[int]bool{}}
|
||
}
|
||
|
||
func (v *volcASR) start(ctx context.Context) error {
|
||
hdr := http.Header{}
|
||
if v.cfg.VolcAppKey != "" { // 新版控制台:单一 APP Key
|
||
hdr.Set("X-Api-Key", v.cfg.VolcAppKey)
|
||
} else if v.cfg.VolcAppID != "" && v.cfg.VolcAccessToken != "" { // 旧版控制台:APP ID + Access Token
|
||
hdr.Set("X-Api-App-Key", v.cfg.VolcAppID)
|
||
hdr.Set("X-Api-Access-Key", v.cfg.VolcAccessToken)
|
||
} else {
|
||
return fmt.Errorf("未配置火山凭证:新版控制台设 VOLC_ASR_APP_KEY,旧版设 VOLC_ASR_APP_ID + VOLC_ASR_ACCESS_TOKEN(均需在语音技术控制台获取,非 VOLCENGINE_ACCESS_KEY)")
|
||
}
|
||
hdr.Set("X-Api-Resource-Id", v.cfg.VolcResourceID)
|
||
hdr.Set("X-Api-Request-Id", uuid())
|
||
hdr.Set("X-Api-Connect-Id", uuid())
|
||
hdr.Set("X-Api-Sequence", "-1")
|
||
|
||
conn, resp, err := websocket.DefaultDialer.DialContext(ctx, volcURL, hdr)
|
||
if err != nil {
|
||
logid := ""
|
||
if resp != nil {
|
||
logid = resp.Header.Get("X-Tt-Logid")
|
||
}
|
||
return fmt.Errorf("连接火山 ASR (401/403 多为凭证或资源未开通, logid=%s): %w", logid, err)
|
||
}
|
||
v.conn = conn
|
||
|
||
req := map[string]any{
|
||
"user": map[string]any{"uid": "voice-test-agent"},
|
||
"audio": map[string]any{"format": "pcm", "rate": 16000, "bits": 16, "channel": 1},
|
||
"request": map[string]any{
|
||
"model_name": "bigmodel",
|
||
"enable_itn": true,
|
||
"enable_punc": true,
|
||
"result_type": "full",
|
||
"show_utterances": true,
|
||
},
|
||
}
|
||
body, _ := json.Marshal(req)
|
||
if err := v.conn.WriteMessage(websocket.BinaryMessage, volcFrame(msgFullClient, flagNoSeq, serJSON, compNone, body)); err != nil {
|
||
return fmt.Errorf("发送 full client request: %w", err)
|
||
}
|
||
go v.readLoop()
|
||
return nil
|
||
}
|
||
|
||
// volcFrame 构造一帧二进制协议。
|
||
func volcFrame(msgType, flags, ser, comp byte, payload []byte) []byte {
|
||
buf := bytes.NewBuffer(make([]byte, 0, 12+len(payload)))
|
||
buf.WriteByte(0x11) // ver=1, header=1*4B
|
||
buf.WriteByte(msgType<<4 | flags)
|
||
buf.WriteByte(ser<<4 | comp)
|
||
buf.WriteByte(0x00)
|
||
binary.Write(buf, binary.BigEndian, uint32(len(payload)))
|
||
buf.Write(payload)
|
||
return buf.Bytes()
|
||
}
|
||
|
||
func (v *volcASR) readLoop() {
|
||
defer v.closeUpstream()
|
||
for {
|
||
mt, data, err := v.conn.ReadMessage()
|
||
if err != nil {
|
||
v.emit(asrEvent{Typ: "error", Error: fmt.Sprintf("连接关闭: %v", err)})
|
||
return
|
||
}
|
||
if mt != websocket.BinaryMessage || len(data) < 8 {
|
||
continue
|
||
}
|
||
msgType, flags := data[1]>>4, data[1]&0x0F
|
||
comp := data[2] & 0x0F
|
||
off := int(data[0]&0x0F) * 4
|
||
|
||
if flags != 0 { // 带 sequence 的响应帧(1=正常 / 2、3=负包结束)
|
||
if len(data) < off+4 {
|
||
continue
|
||
}
|
||
off += 4 // sequence 值不用
|
||
}
|
||
var code uint32
|
||
if msgType == msgError {
|
||
if len(data) < off+4 {
|
||
continue
|
||
}
|
||
code = binary.BigEndian.Uint32(data[off : off+4])
|
||
off += 4
|
||
}
|
||
if len(data) < off+4 {
|
||
continue
|
||
}
|
||
size := int(binary.BigEndian.Uint32(data[off : off+4]))
|
||
payload := data[off+4 : off+4+size]
|
||
if comp == compGzip {
|
||
if r, err := gzip.NewReader(bytes.NewReader(payload)); err == nil {
|
||
payload, _ = io.ReadAll(r)
|
||
}
|
||
}
|
||
|
||
switch msgType {
|
||
case msgFullServer:
|
||
var resp struct {
|
||
Code int `json:"code"`
|
||
Message string `json:"message"`
|
||
Result struct {
|
||
Text string `json:"text"`
|
||
Utterances []struct {
|
||
Text string `json:"text"`
|
||
Definite bool `json:"definite"`
|
||
} `json:"utterances"`
|
||
} `json:"result"`
|
||
}
|
||
if json.Unmarshal(payload, &resp) != nil {
|
||
continue
|
||
}
|
||
if resp.Code != 0 && resp.Code != 20000000 {
|
||
v.emit(asrEvent{Typ: "error", Code: fmt.Sprint(resp.Code), Error: resp.Message})
|
||
return
|
||
}
|
||
// 句级 final:utterance 固化即发出(连续对话不依赖结束帧)
|
||
for i, u := range resp.Result.Utterances {
|
||
if u.Definite && !v.emitted[i] {
|
||
v.emitted[i] = true
|
||
v.emit(asrEvent{Typ: "final", Text: u.Text})
|
||
}
|
||
}
|
||
// partial:最后一个未固化 utterance
|
||
if n := len(resp.Result.Utterances); n > 0 && !resp.Result.Utterances[n-1].Definite {
|
||
v.emit(asrEvent{Typ: "partial", Text: resp.Result.Utterances[n-1].Text})
|
||
}
|
||
// 服务端最终帧(负包):流结束
|
||
if flags == flagLast || flags == flagNegWithSeq {
|
||
return
|
||
}
|
||
case msgError:
|
||
v.emit(asrEvent{Typ: "error", Code: fmt.Sprint(code), Error: string(payload)})
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
func (v *volcASR) emit(e asrEvent) {
|
||
select {
|
||
case v.out <- e:
|
||
default:
|
||
}
|
||
}
|
||
|
||
func (v *volcASR) sendAudio(b []byte) error {
|
||
v.wmu.Lock()
|
||
defer v.wmu.Unlock()
|
||
return v.conn.WriteMessage(websocket.BinaryMessage, volcFrame(msgAudioOnly, 0, 0, compNone, b))
|
||
}
|
||
|
||
func (v *volcASR) finish() error {
|
||
// 负包:flags=2,空 payload
|
||
v.wmu.Lock()
|
||
defer v.wmu.Unlock()
|
||
return v.conn.WriteMessage(websocket.BinaryMessage, volcFrame(msgAudioOnly, flagLast, 0, compNone, nil))
|
||
}
|
||
|
||
func (v *volcASR) events() <-chan asrEvent { return v.out }
|
||
func (v *volcASR) close() { v.closeUpstream() }
|
||
|
||
func (v *volcASR) closeUpstream() {
|
||
v.once.Do(func() {
|
||
if v.conn != nil {
|
||
v.conn.Close()
|
||
}
|
||
close(v.out)
|
||
})
|
||
}
|